UC-SUP-001. Приём обращения и назначение оператора
Назначение
Описывает сквозной сценарий Службы заботы: заявитель подаёт обращение (форма обратной связи), сервис
support детерминированно маршрутизирует его в очередь и оповещает дежурных, после чего оператор берёт
тикет в работу либо супервизор назначает ответственного. Связан с контекстом support; агрегаты —
TicketEntity, TicketQueueEntity. Покрывает матрицу
4.4 (единая очередь) и 4.5 (управление тикетами).
Metadata
| Поле | Значение |
|---|---|
| ID | UC-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) |
| Tenant | TenantId = PIN CompanyId (единый tenant платформы); контекст заявителя — RequesterCompanyId/RequesterRole |
| Record-level | после взятия/назначения — по AssigneeUserId; супервизор не ограничен |
Предусловие
- Заявитель аутентифицирован (JWT
identity); SEED-очереди сконфигурированы (ровно одна активная default, INV-Q-2). - Оператор/супервизор аутентифицированы и несут platform-scope permission Службы заботы.
- Реплика заявителя из
identityможет ещё не прийти на момент создания — это допустимо (деградация display, не блокировка).
Гарантируется после сценария: заявитель имеет обращение с Number = SUP-NNN в статусе New → InWork
(после взятия/назначения); опубликованы ticket-created.v1 и (при смене статуса) ticket-status-changed.v1;
дежурные операторы и заявитель уведомлены в окне < 1 мин.
Триггер
| Поле | Значение |
|---|---|
| Источник | UI button / API call / Kafka event |
| Триггерящий элемент | Форма обратной связи заявителя; кабинет оператора (очередь тикетов) |
Основной Поток
- Заявитель заполняет форму (тема/категория/текст/вложения) →
POST /api/public/support/tickets(create-ticket). - Сервис проверяет вход и по категории обращения детерминированно определяет очередь маршрутизации (ticket-queue.md, INV-Q-5:
RoutingCategory→ активная очередь, иначе default); подпись ФИО заявителя берётся из identity-lookup (timeout 2с, при недоступности — graceful, реплика догоняет консюмером). - Создаётся обращение (INV-TKT-1):
Status = New,AssigneeUserId = null,Channel = WebForm,Priority = DefaultPriorityочереди,Number = SUP-NNN,FirstResponseDueAtпо SLA-политике; первое сообщение заявителя добавляется как публичное. - Обращение, первое сообщение и событие
TicketCreated(в outbox) фиксируются в одном согласованном изменении →201, карточка тикета. - Консюмер
notificationsпринимаетpin.support.ticket-created.v1и оповещает дежурных операторов очереди. - Оператор открывает свободный тикет и берёт в работу →
POST /api/support/tickets/{id}/take(take-ticket) — INV-TKT-2: переходNew → InWork,AssigneeUserId= оператор. - Переход и событие
TicketStatusChanged(New → InWork, в outbox) фиксируются в одном согласованном изменении; конкурентный захват исключается оптимистичной блокировкой (параллельная попытка →SUPPORT_TICKET_CONCURRENCY_CONFLICT) →200. - Фронт обновляет очередь и карточку;
notifications(консюмер статуса) сообщает заявителю «обращение в работе».
Альтернативные Потоки
A1. Назначение супервизором вместо взятия
- Супервизор назначает ответственного →
POST /api/support/tickets/{id}/assign(assign-ticket);identityподтверждаетsupport.tickets.manageу назначаемого. - Назначение (INV-TKT-3): для
New— авто-переходNew → InWork(публикуется событие статуса); дляInWork— чистое переназначение (без интеграционного события).
Возвращается к основному потоку на шаг 8.
A2. Заведение оператором за заявителя (устное/телефонное)
- Оператор →
POST /api/support/tickets(create-ticket-on-behalf): каналOperator,RequesterUserIdявно, синхронный identity-lookup реплики. - Тикет создаётся
New, далее — взятие/назначение (шаги 6–8 / A1).
Возвращается к основному потоку на шаг 6.
A3. Нет сконфигурированной default-очереди
- Маршрутизация не находит ни routing-, ни default-очередь →
SUPPORT_QUEUE_NOT_CONFIGURED(500), тикет не создан.
Завершается ошибкой; требуется SEED/конфигурация очередей (INV-Q-2).
A4. Невалидный ввод формы (граничные значения)
Subjectпустой или> 256; тело первого сообщения пустое; число/размер вложений сверх лимита →VALIDATION_FAILED(400), тикет не создан, мутаций нет.
Завершается ошибкой валидации; заявитель правит поля и повторяет.
A5. Назначение недопустимого оператора
- Супервизор назначает пользователя, который не оператор / без
support.tickets.manage/ заблокирован →SUPPORT_ASSIGNEE_INVALID(422); назначения не происходит, статус не меняется. - Тикет уже за другим оператором, а супервизор пытается «взять» вместо переназначения →
SUPPORT_TICKET_ALREADY_ASSIGNED(409).
Завершается ошибкой; супервизор выбирает другого исполнителя (возврат к A1).
A6. Повторное/конкурентное взятие
- Повторное взятие тем же уже назначенным оператором — no-op: статус
InWorkсохраняется, новое событие статуса не публикуется, ошибка не возвращается (естественная идемпотентность). - Два оператора берут один
New-тикет одновременно: оптимистичная блокировка пропускает первого (InWork), второй →SUPPORT_TICKET_CONCURRENCY_CONFLICT(409) и перечитывает свежий статус.
Возвращается к основному потоку на шаг 8 (победитель) / повтор чтения (проигравший).
Постусловие
- Создан
TicketEntity(Status = New→InWorkпосле взятия/назначения) + первоеTicketMessageEntity(Public);Number = SUP-NNN. AssigneeUserIdзадан после взятия/назначения;UpdatedAt/UpdatedByUserId/Versionобновлены.- В outbox —
TicketCreatedEventи (при смене статуса)TicketStatusChangedEvent; операторы/заявитель уведомлены.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
UNAUTHENTICATED | 401 | нет/невалидный JWT | Отправить на вход |
VALIDATION_FAILED | 400 | Subject/Body/Attachments вне ограничений | Подсветить поля формы |
SUPPORT_QUEUE_NOT_CONFIGURED | 500 | нет активной default-очереди (INV-Q-2) | Показать «сервис недоступен» |
SUPPORT_TICKET_NOT_FOUND | 404 | тикет не найден/скрыт | Обновить очередь |
SUPPORT_TICKET_INVALID_STATE | 409 | взятие/назначение вне {New, InWork} | Перечитать статус |
SUPPORT_TICKET_ALREADY_ASSIGNED | 409 | тикет уже за другим оператором | Показать назначенного |
SUPPORT_ASSIGNEE_INVALID | 422 | назначаемый не оператор/без прав/заблокирован | Выбрать другого |
SUPPORT_TICKET_CONCURRENCY_CONFLICT | 409 | гонка захвата (оптимистичная блокировка) | Перечитать (GET) и повторить |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
TicketCreated | Ticket.Create | сборщик TicketCreatedEvent, счётчики очереди |
TicketStatusChanged | Ticket.Take/Ticket.Assign (New → InWork) | таймлайн, сборщик TicketStatusChangedEvent |
TicketReassigned | Ticket.Assign (InWork → InWork) | внутренний таймлайн (интеграционного события нет) |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
TicketCreatedEvent | pin.support.ticket-created.v1 | создание тикета (шаг 4) — контракт | notifications (дежурные очереди) |
TicketStatusChangedEvent | pin.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/RequesterEmailfan-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, статус не затёрт |
| AC5 | New-тикет, супервизор с support.tickets.assign | супервизор назначает оператора | авто-переход New → InWork, AssigneeUserId задан, событие статуса опубликовано |
| AC6 | Синхронный identity-lookup недоступен/таймаут на создании | заявитель создаёт тикет | тикет создан без ФИО (не заблокирован); ФИО дорисовывается консюмером identity (секунды) |
| AC7 | Нет активной default-очереди (нарушен INV-Q-2) | заявитель создаёт тикет | SUPPORT_QUEUE_NOT_CONFIGURED (500), тикет не создан |
| AC8 | Subject пуст/>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.
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (1): UC-SUP-002. Решение обращения
- Дашборды (1): Панель Наблюдаемости: support-overview (эксплуатация)
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- API: create-ticket, create-ticket-on-behalf, take-ticket, assign-ticket.
- Domain: ticket.md (INV-TKT-1/2/3), ticket-queue.md (INV-Q-2/5).
- События: ticket-created.md, ticket-status-changed.md; консюмер реплики — identity-user-upserted.md.
- Контекст: README; tenancy — ADR-0052, масштаб/фон — ADR-0056.