Перейти к основному содержимому

UC-SUP-001. Приём обращения и назначение оператора

Назначение

Описывает сквозной сценарий Службы заботы: заявитель подаёт обращение (форма обратной связи), сервис support детерминированно маршрутизирует его в очередь и оповещает дежурных, после чего оператор берёт тикет в работу либо супервизор назначает ответственного. Связан с контекстом support; агрегаты — TicketEntity, TicketQueueEntity. Покрывает матрицу 4.4 (единая очередь) и 4.5 (управление тикетами).

Metadata

ПолеЗначение
IDUC-SUP-001
Контекстsupport (см. docs/06-services/support/)
Типосновной (+ альтернативные потоки A1–A3)
Статусdraft

Актор

ПолеЗначение
РольСлужбаЗаботы — Оператор (взятие) / Супервизор (назначение); инициатор — Заявитель (любой авторизованный)
Permissions / scopeОператор — support.tickets.manage; Супервизор — support.tickets.assign; заявитель — own action (без permission) — все platform-scope (ADR-0052)
TenantTenantId = PIN CompanyId (единый tenant платформы); контекст заявителя — RequesterCompanyId/RequesterRole
Record-levelпосле взятия/назначения — по AssigneeUserId; супервизор не ограничен

Предусловие

  • Заявитель аутентифицирован (JWT identity); SEED-очереди сконфигурированы (ровно одна активная default, INV-Q-2).
  • Оператор/супервизор аутентифицированы и несут platform-scope permission Службы заботы.
  • Реплика заявителя из identity может ещё не прийти на момент создания — это допустимо (деградация display, не блокировка).

Гарантируется после сценария: заявитель имеет обращение с Number = SUP-NNN в статусе NewInWork (после взятия/назначения); опубликованы ticket-created.v1 и (при смене статуса) ticket-status-changed.v1; дежурные операторы и заявитель уведомлены в окне < 1 мин.

Триггер

ПолеЗначение
ИсточникUI button / API call / Kafka event
Триггерящий элементФорма обратной связи заявителя; кабинет оператора (очередь тикетов)

Основной Поток

  1. Заявитель заполняет форму (тема/категория/текст/вложения) → POST /api/public/support/tickets (create-ticket).
  2. Сервис проверяет вход и по категории обращения детерминированно определяет очередь маршрутизации (ticket-queue.md, INV-Q-5: RoutingCategory → активная очередь, иначе default); подпись ФИО заявителя берётся из identity-lookup (timeout 2с, при недоступности — graceful, реплика догоняет консюмером).
  3. Создаётся обращение (INV-TKT-1): Status = New, AssigneeUserId = null, Channel = WebForm, Priority = DefaultPriority очереди, Number = SUP-NNN, FirstResponseDueAt по SLA-политике; первое сообщение заявителя добавляется как публичное.
  4. Обращение, первое сообщение и событие TicketCreated (в outbox) фиксируются в одном согласованном изменении → 201, карточка тикета.
  5. Консюмер notifications принимает pin.support.ticket-created.v1 и оповещает дежурных операторов очереди.
  6. Оператор открывает свободный тикет и берёт в работу → POST /api/support/tickets/{id}/take (take-ticket) — INV-TKT-2: переход New → InWork, AssigneeUserId = оператор.
  7. Переход и событие TicketStatusChanged (New → InWork, в outbox) фиксируются в одном согласованном изменении; конкурентный захват исключается оптимистичной блокировкой (параллельная попытка → SUPPORT_TICKET_CONCURRENCY_CONFLICT) → 200.
  8. Фронт обновляет очередь и карточку; notifications (консюмер статуса) сообщает заявителю «обращение в работе».

Альтернативные Потоки

A1. Назначение супервизором вместо взятия

  1. Супервизор назначает ответственного → POST /api/support/tickets/{id}/assign (assign-ticket); identity подтверждает support.tickets.manage у назначаемого.
  2. Назначение (INV-TKT-3): для New — авто-переход New → InWork (публикуется событие статуса); для InWork — чистое переназначение (без интеграционного события).

Возвращается к основному потоку на шаг 8.

A2. Заведение оператором за заявителя (устное/телефонное)

  1. Оператор → POST /api/support/tickets (create-ticket-on-behalf): канал Operator, RequesterUserId явно, синхронный identity-lookup реплики.
  2. Тикет создаётся New, далее — взятие/назначение (шаги 6–8 / A1).

Возвращается к основному потоку на шаг 6.

A3. Нет сконфигурированной default-очереди

  1. Маршрутизация не находит ни routing-, ни default-очередь → SUPPORT_QUEUE_NOT_CONFIGURED (500), тикет не создан.

Завершается ошибкой; требуется SEED/конфигурация очередей (INV-Q-2).

A4. Невалидный ввод формы (граничные значения)

  1. Subject пустой или > 256; тело первого сообщения пустое; число/размер вложений сверх лимита → VALIDATION_FAILED (400), тикет не создан, мутаций нет.

Завершается ошибкой валидации; заявитель правит поля и повторяет.

A5. Назначение недопустимого оператора

  1. Супервизор назначает пользователя, который не оператор / без support.tickets.manage / заблокирован → SUPPORT_ASSIGNEE_INVALID (422); назначения не происходит, статус не меняется.
  2. Тикет уже за другим оператором, а супервизор пытается «взять» вместо переназначения → SUPPORT_TICKET_ALREADY_ASSIGNED (409).

Завершается ошибкой; супервизор выбирает другого исполнителя (возврат к A1).

A6. Повторное/конкурентное взятие

  1. Повторное взятие тем же уже назначенным оператором — no-op: статус InWork сохраняется, новое событие статуса не публикуется, ошибка не возвращается (естественная идемпотентность).
  2. Два оператора берут один New-тикет одновременно: оптимистичная блокировка пропускает первого (InWork), второй → SUPPORT_TICKET_CONCURRENCY_CONFLICT (409) и перечитывает свежий статус.

Возвращается к основному потоку на шаг 8 (победитель) / повтор чтения (проигравший).

Постусловие

  • Создан TicketEntity (Status = NewInWork после взятия/назначения) + первое TicketMessageEntity (Public); Number = SUP-NNN.
  • AssigneeUserId задан после взятия/назначения; UpdatedAt/UpdatedByUserId/Version обновлены.
  • В outbox — TicketCreatedEvent и (при смене статуса) TicketStatusChangedEvent; операторы/заявитель уведомлены.

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
UNAUTHENTICATED401нет/невалидный JWTОтправить на вход
VALIDATION_FAILED400Subject/Body/Attachments вне ограниченийПодсветить поля формы
SUPPORT_QUEUE_NOT_CONFIGURED500нет активной default-очереди (INV-Q-2)Показать «сервис недоступен»
SUPPORT_TICKET_NOT_FOUND404тикет не найден/скрытОбновить очередь
SUPPORT_TICKET_INVALID_STATE409взятие/назначение вне {New, InWork}Перечитать статус
SUPPORT_TICKET_ALREADY_ASSIGNED409тикет уже за другим операторомПоказать назначенного
SUPPORT_ASSIGNEE_INVALID422назначаемый не оператор/без прав/заблокированВыбрать другого
SUPPORT_TICKET_CONCURRENCY_CONFLICT409гонка захвата (оптимистичная блокировка)Перечитать (GET) и повторить

Доменные События

СобытиеКогда поднимаетсяSubscribers
TicketCreatedTicket.Createсборщик TicketCreatedEvent, счётчики очереди
TicketStatusChangedTicket.Take/Ticket.Assign (New → InWork)таймлайн, сборщик TicketStatusChangedEvent
TicketReassignedTicket.Assign (InWork → InWork)внутренний таймлайн (интеграционного события нет)

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
TicketCreatedEventpin.support.ticket-created.v1создание тикета (шаг 4) — контрактnotifications (дежурные очереди)
TicketStatusChangedEventpin.support.ticket-status-changed.v1взятие/назначение с New → InWork (шаг 7 / A1) — контрактnotifications (заявитель + назначенный оператор)

Кросс-сервисные ожидания и согласованность

Ожидает от соседей (вход)

  • identity — авторизация и справка о заявителе (синхронно). JWT-identity актора + Scoped-RBAC platform-scope permissions (support.tickets.manage/.assign); недоступность → 401/503 на команде. Подпись ФИО заявителя — синхронный lookup реплики при создании с таймаутом 2 с; при таймауте/недоступности тикет создаётся без ФИО (graceful), поле дорисовывается консюмером.
  • identity — событие pin.identity.users.v1 (асинхронно). Наполняет display-реплику RequesterName/RequesterPhone/RequesterEmail fan-out по RequesterUserId; version-guard по IdentityRequesterVersion (применяется только строго более новая версия, устаревшее/повторное — игнорируется без ошибки). Реплика не пришла → карточка/очередь показывают RequesterUserId без ФИО, приём и взятие в работу не блокируются.

Предоставляет соседям (выход)

  • pin.support.ticket-created.v1 (ключ = TicketId, ContractVersion = 1, 3 партиции): факт создания New-тикета с маршрутным snapshot (QueueId/Category/Priority/Channel/RequesterUserId/ RequesterCompanyId?/RelatedDealId?); потребитель notifications оповещает дежурных очереди. Порядок per-ticket гарантирован ключом; ФИО/телефон/тело обращения в payload не входят (PII резолвит потребитель из собственной реплики identity).
  • pin.support.ticket-status-changed.v1 (ключ = TicketId, ContractVersion = 1): переход New → InWork при взятии/назначении; потребитель notifications уведомляет заявителя и назначенного оператора. Публикуется атомарно с переходом, ровно один раз на переход.

Окно согласованности и промежуточное состояние

  • Создание → оповещение оператора: асинхронно через outbox, окно < 1 мин. До прихода уведомления тикет уже New в очереди и виден оператору при обновлении списка; заявитель сразу получает Number (SUP-NNN) и статус «принято».
  • Взятие/назначение → уведомление заявителя: асинхронно, < 1 мин. В промежутке заявитель видит New до доставки уведомления «в работе», оператор — уже InWork.
  • Реплика ФИО: секунды после consume; в окне рассинхрона карточка показывает id без подписи.

Идемпотентность и конкурентность

  • Повторное взятие тем же назначенным актором — no-op без нового ticket-status-changed и без ошибки.
  • Гонка захвата разводится оптимистичной блокировкой: побеждает первый зафиксировавший, проигравший получает SUPPORT_TICKET_CONCURRENCY_CONFLICT и перечитывает свежий статус.
  • Дубли интеграционных событий у потребителя снимаются дедупликацией по MessageId; недоставленное событие копится в outbox и повторяется (at-least-once).

Acceptance Criteria

#Given (дано)When (когда)Then (тогда)
AC1Обращение категории Deal; активна routing-очередь Dealsзаявитель создаёт тикеттикет уходит в Deals; категория без routing-очереди (напр. Booking) → default General (INV-Q-5)
AC2Тикет создан и зафиксированизменение закоммиченоticket-created.v1 (ключ TicketId, v1) в outbox; notifications получает событие и оповещает дежурных очереди в окне < 1 мин
AC3Свободный New-тикет, актор с support.tickets.manageоператор берёт тикетNew → InWork, AssigneeUserId = actor, опубликован ticket-status-changed.v1
AC4Один New-тикет, два оператораоба берут одновременнопобедитель — InWork; проигравший → SUPPORT_TICKET_CONCURRENCY_CONFLICT, статус не затёрт
AC5New-тикет, супервизор с support.tickets.assignсупервизор назначает оператораавто-переход New → InWork, AssigneeUserId задан, событие статуса опубликовано
AC6Синхронный identity-lookup недоступен/таймаут на созданиизаявитель создаёт тикеттикет создан без ФИО (не заблокирован); ФИО дорисовывается консюмером identity (секунды)
AC7Нет активной default-очереди (нарушен INV-Q-2)заявитель создаёт тикетSUPPORT_QUEUE_NOT_CONFIGURED (500), тикет не создан
AC8Subject пуст/>256 или тело первого сообщения пусто/вложения сверх лимитазаявитель отправляет формуVALIDATION_FAILED (400), мутаций нет
AC9Назначаемый — не оператор/без прав/заблокировансупервизор назначаетSUPPORT_ASSIGNEE_INVALID (422), назначения нет
AC10Тикет уже InWork за акторомоператор повторяет взятиеno-op: InWork сохранён, нового события статуса нет, ошибки нет

Наблюдаемость и НФТ

  • Латентность: команды приёма/взятия/назначения p95 < 500 мс; синхронный identity-lookup ограничен таймаутом 2 с (сверх — graceful без ФИО). Целевой объём — 1–10k активных пользователей (ADR-0056), тикетов немного.
  • Логи (без PII): ticket-created (ticketId, number, queueId, category, channel), ticket-taken/ ticket-assigned (ticketId, assigneeUserId, actor); ФИО/телефон/email — маскируются.
  • Метрики: счётчики создания/взятия/назначения, распределение по очередям/категориям, конкурентные конфликты захвата, доля тикетов без реплики ФИО на момент создания; лаг консюмера identity (> 5 мин — alert), backlog outbox (> 1000 — alert).
  • Доступ: приём — любой авторизованный (own action); взятие — support.tickets.manage; назначение — support.tickets.assign (всё platform-scope); после взятия — record-level по AssigneeUserId.

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные Документы