UC-SUP-002. Решение обращения
Назначение
Описывает работу оператора Службы заботы над взятым обращением: публичный ответ заявителю (опционально из
шаблона и со ссылкой на статью базы знаний), перевод в Resolved, подтверждение и закрытие, а также реопен
при возврате заявителя. Связан с контекстом support; агрегат — TicketEntity (+
переписка TicketMessageEntity, шаблоны ticket-template.md).
Покрывает матрицу 4.1 (переписка тикета), 4.3 (шаблоны), 4.5 (жизненный цикл), 4.7 (база знаний).
Metadata
| Поле | Значение |
|---|---|
| ID | UC-SUP-002 |
| Контекст | support (см. docs/06-services/support/) |
| Тип | основной (+ альтернативные потоки A1–A3) |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | СлужбаЗаботы — Оператор |
| Permissions / scope | support.tickets.manage (ответ/статус); support.kb.view (ссылка на KB); support.templates.manage — не требуется для применения — platform-scope (ADR-0052) |
| Tenant | TenantId = PIN CompanyId (единый tenant платформы) |
| Record-level | по Ticket.AssigneeUserId (оператор ведёт назначенный тикет); супервизор с support.tickets.assign не ограничен |
Предусловие
- Тикет существует и не скрыт;
Status ∈ {InWork, Resolved}(взят по UC-SUP-001);AssigneeUserIdзадан. - Оператор аутентифицирован, несёт
support.tickets.manage(record-level по assignee). - Для перехода в
Resolvedу тикета уже заданAssigneeUserId(INV-TKT-4).
Гарантируется после сценария: в тред добавлены сообщения (append-only); Status = Resolved → Closed
(или InWork при реопене); FirstResponseAt проставлен один раз; при шаблоне UsageCount++; опубликованы
ticket-message-posted.v1 (только Public) и ticket-status-changed.v1 на каждый переход; заявитель
уведомлён в окне < 1 мин.
Триггер
| Поле | Значение |
|---|---|
| Источник | UI button / API call |
| Триггерящий элемент | Карточка тикета в кабинете оператора (тред переписки, кнопки «Ответить»/«Решить»/«Закрыть») |
Основной Поток
- Оператор открывает карточку тикета →
GET /api/support/tickets/{id}(get-ticket) с тредом переписки (постранично). - Пишет публичный ответ (опц. выбирает шаблон) →
POST /api/support/tickets/{id}/messages(reply-ticket,Visibility = Public, XORBody/TemplateId). - Проверка: тикет не в
Closed(иначеSUPPORT_TICKET_CLOSED); при шаблоне — рендер только whitelisted-плейсхолдеров (INV-TPL-3) и инкрементUsageCountшаблона; добавляется публичное сообщение оператора. - INV-TKT-5: первый публичный ответ выставляет
FirstResponseAt = now(SLA);MessagesCount++,LastMessageAt = now. Сообщение, счётчикUsageCountшаблона и событиеticket-message-posted.v1(толькоPublic, в outbox) фиксируются в одном согласованном изменении →201. - После получения нужных данных оператор решает обращение →
POST /api/support/tickets/{id}/status(change-ticket-status,NewStatus = Resolved). - Переход в
Resolved(INV-TKT-4): требуетсяAssigneeUserId != null(иначеSUPPORT_TICKET_NO_ASSIGNEE),ResolvedAt = now; событиеTicketStatusChanged(InWork → Resolved) в outbox. - Заявитель подтверждает / истекает окно — оператор закрывает →
change-ticket-statusNewStatus = Closed(Resolved → Closed,ClosedAt = now); публикуется событие статуса. notifications(консюмерticket-status-changed) уведомляет заявителя о решении/закрытии; фронт обновляет карточку.
Альтернативные Потоки
A1. Внутренняя заметка (не видна заявителю)
- Оператор пишет
reply-ticketсVisibility = Internal— добавляется внутренняя заметка (INV-MSG-2). FirstResponseAtне ставится (не публичный ответ), интеграционное событие не публикуется (Internal-заметки в шину не уходят).
Возвращается к основному потоку на шаг 5.
A2. Реопен вернувшегося обращения
- Заявитель отвечает по закрытому/решённому тикету; оператор →
change-ticket-statusNewStatus = InWork. Resolved → InWorkилиClosed → InWork(INV-TKT-4/INV-TKT-7):ReopenedCount++,ResolvedAt/ClosedAt = null; событие статуса.
Возвращается к основному потоку на шаг 2.
A3. Ссылка на статью базы знаний
- Оператор ищет статью →
GET /api/support/kb-articles(list-kb-articles, FTS) и вставляет ссылку/выдержку в публичный ответ. - Ответ отправляется как шаг 2 (
Public); KB не мутируется (только чтение, аудиторияPublic/Internal).
Возвращается к основному потоку на шаг 5.
A4. Ответ в закрытый тикет без реопена
- Оператор пытается ответить/сменить статус в тикете
Status = Closedбез реопена →SUPPORT_TICKET_CLOSED(409, INV-MSG-4/INV-TKT-7); сообщение не добавлено, событие не публикуется. - Корректный путь — сначала реопен (A2), затем ответ.
Завершается ошибкой; направляет на реопен.
A5. Недопустимый переход статуса
ResolveприAssigneeUserId == null→SUPPORT_TICKET_NO_ASSIGNEE(409): сначала взять/назначить (UC-SUP-001).- Запрос перехода вне таблицы INV-TKT-4 (напр.
New → Resolved,Closed → Resolved) →SUPPORT_TICKET_INVALID_TRANSITION(409); мутаций и события нет. NewStatusвне{InWork, Resolved, Closed}или нарушен XORBody/TemplateId→VALIDATION_FAILED(400).
Завершается ошибкой; показать допустимые действия.
A6. Архивированный шаблон
- Оператор применяет шаблон в статусе
Archived→SUPPORT_TEMPLATE_ARCHIVED(409, INV-TPL-4); ответ не отправлен,UsageCountне меняется.
Завершается ошибкой; выбрать другой шаблон.
A7. Гонка оператор ↔ SLA-worker
- Оператор фиксирует ответ/переход одновременно с фоновым SLA-worker, помечающим
SlaBreached/*DueAt: оптимистичная блокировка пропускает первого, проигравший →SUPPORT_TICKET_CONCURRENCY_CONFLICT(409) и повторяет на свежей версии (его решение не затирает чужое). - При одновременных первых публичных ответах двух операторов
FirstResponseAt(INV-TKT-5) проставляет только выигравший переход; второй метку не перезаписывает.
Возвращается к основному потоку на шаг 5 после перечитывания.
Постусловие
- В
support.ticket_messagesдобавлены сообщения (append-only);MessagesCount/LastMessageAt/FirstResponseAtобновлены. TicketEntity.Status=Resolved→Closed(илиInWorkпри реопене);ResolvedAt/ClosedAt/ReopenedCountсогласованы;Version++.- Применённый шаблон:
TicketTemplateEntity.UsageCount++. В outbox —ticket-message-posted.v1(Public) иticket-status-changed.v1.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
FORBIDDEN | 403 | нет support.tickets.manage / не назначенный без support.tickets.assign | Скрыть действия |
VALIDATION_FAILED | 400 | нарушен XOR Body/TemplateId; NewStatus вне {InWork,Resolved,Closed} | Подсветить поля |
SUPPORT_TICKET_NOT_FOUND | 404 | тикет не найден/скрыт/чужой tenant | Обновить список |
SUPPORT_TEMPLATE_ARCHIVED | 409 | шаблон архивирован (INV-TPL-4) | Выбрать другой шаблон |
SUPPORT_TICKET_CLOSED | 409 | ответ в Closed без реопена (INV-MSG-4/INV-TKT-7) | Сначала реопен |
SUPPORT_TICKET_INVALID_TRANSITION | 409 | недопустимый переход (INV-TKT-4) | Показать допустимые действия |
SUPPORT_TICKET_NO_ASSIGNEE | 409 | Resolved при AssigneeUserId == null | Сначала взять/назначить |
SUPPORT_TICKET_CONCURRENCY_CONFLICT | 409 | гонка оператор ↔ SLA-worker (оптимистичная блокировка) | Перечитать и повторить |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
TicketMessageAdded | Ticket.AddMessage | счётчики/FirstResponseAt, сборщик TicketMessagePostedEvent (Public) |
TicketStatusChanged | Ticket.ChangeStatus (Resolve/Close/Reopen) | таймлайн, сборщик TicketStatusChangedEvent |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
TicketMessagePostedEvent | pin.support.ticket-message-posted.v1 | публичный ответ (шаг 4) — контракт | notifications (заявитель: новый ответ) |
TicketStatusChangedEvent | pin.support.ticket-status-changed.v1 | решение/закрытие/реопен (шаги 6–7, A2) — контракт | notifications (заявитель) |
Кросс-сервисные ожидания и согласованность
Ожидает от соседей (вход)
identity(синхронно/асинхронно). JWT +support.tickets.manage(record-level поAssigneeUserId); недоступность →401/403/503. Display-подпись заявителя — из репликиidentity(version-guard); при отставании реплики карточка/тред показывают id без ФИО, ответ и переход статуса не блокируются.- База знаний (внутри
support, только чтение).list-kb-articles— Elasticsearch-FTS; при недоступности поиска KB ответ по-прежнему можно написать вручную (PostgreSQL — source of truth команд). KB не мутируется.
Предоставляет соседям (выход)
pin.support.ticket-message-posted.v1(ключ =TicketId,ContractVersion = 1, 3 партиции): публикуется только дляVisibility = Public; несётNumber/RequesterUserId/AuthorUserId/HasAttachments(без тела ответа — PII не в шине). Потребительnotificationsуведомляет заявителя о новом ответе.Internal-заметки и сообщения самого заявителя события не порождают.pin.support.ticket-status-changed.v1(ключ =TicketId,ContractVersion = 1): на каждый переходResolve/Close/Reopen— атомарно с мутацией, ровно один раз; per-ticket порядок гарантирован ключом.
Окно согласованности и промежуточное состояние
- Публичный ответ/смена статуса → уведомление заявителя: асинхронно через outbox, < 1 мин. В промежутке заявитель видит прежнее состояние карточки до доставки уведомления; оператор — уже обновлённое.
- Инкремент
UsageCountшаблона иFirstResponseAtфиксируются в том же согласованном изменении, что и сообщение — рассинхрона между тредом и счётчиком нет.
Идемпотентность и конкурентность
- Повторный
change-ticket-statusна уже достигнутый статус — no-op без нового события; недопустимый переход →SUPPORT_TICKET_INVALID_TRANSITION. - Гонка оператор ↔ SLA-worker разводится оптимистичной блокировкой (см. A7): проигравший →
SUPPORT_TICKET_CONCURRENCY_CONFLICT, повторяет на свежей версии. - Дубли интеграционных событий у
notificationsснимаются дедупликацией поMessageId(at-least-once).
Acceptance Criteria
| # | Given (дано) | When (когда) | Then (тогда) |
|---|---|---|---|
| AC1 | InWork-тикет без первого публичного ответа | оператор пишет первый Public-ответ | FirstResponseAt проставлен один раз (INV-TKT-5, IsFirstResponse = true); повторные публичные ответы метку не меняют |
| AC2 | Активный (не Archived) шаблон | оператор применяет шаблон | рендерятся только whitelisted-плейсхолдеры (INV-TPL-3), UsageCount++ в том же изменении |
| AC3 | Оператор пишет Visibility = Internal | заметка добавлена | ticket-message-posted.v1 не публикуется; заметка не видна в public-API заявителя |
| AC4 | InWork-тикет, AssigneeUserId == null | оператор ставит Resolved | SUPPORT_TICKET_NO_ASSIGNEE (409), перехода нет |
| AC5 | Тикет Closed/Resolved, заявитель вернулся | оператор реопенит | Closed → InWork: ReopenedCount++, ResolvedAt/ClosedAt = null, событие статуса |
| AC6 | Любой валидный переход статуса | переход зафиксирован | публикуется ticket-status-changed.v1 (ключ TicketId, v1); notifications уведомляет заявителя < 1 мин |
| AC7 | Тикет Closed | оператор отвечает без реопена | SUPPORT_TICKET_CLOSED (409, INV-MSG-4/INV-TKT-7), сообщение не добавлено |
| AC8 | Шаблон Archived | оператор применяет шаблон | SUPPORT_TEMPLATE_ARCHIVED (409), ответ не отправлен, UsageCount без изменений |
Наблюдаемость и НФТ
- Латентность: ответ/смена статуса p95 < 500 мс; чтение карточки/треда (постранично) p95 < 1 с; поиск KB (Elasticsearch) — свежесть проекции < 1 мин.
- Логи (без PII):
ticket-message-posted(ticketId, messageId, visibility, hasAttachments),ticket-status-changed(ticketId, old→new, reason); тело ответа/заметки и PII заявителя — не логируются. - Метрики: доля тикетов с проставленным
FirstResponseAtв SLA, распределение времени доResolved, частота реопенов (ReopenedCount), применение шаблонов (UsageCount), конфликты оператор ↔ SLA-worker. - Доступ: ответ/статус —
support.tickets.manage(record-level поAssigneeUserId); супервизор сsupport.tickets.assignне ограничен record-level; всё platform-scope.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity: TicketMessageEntity (child of Ticket)
- События (1): Event Contract: pin.support.ticket-message-posted (TicketMessagePostedKafkaEvent)
- Дашборды (1): Панель Наблюдаемости: support-overview (эксплуатация)
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- API: reply-ticket, change-ticket-status, get-ticket, list-kb-articles.
- Domain: ticket.md (INV-TKT-4/5/7), ticket-message.md (INV-MSG-2/4), ticket-template.md (INV-TPL-3/4), knowledge-base-article.md.
- События: ticket-message-posted.md, ticket-status-changed.md.
- Контекст: README; tenancy — ADR-0052.