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

UC-SUP-002. Решение обращения

Назначение

Описывает работу оператора Службы заботы над взятым обращением: публичный ответ заявителю (опционально из шаблона и со ссылкой на статью базы знаний), перевод в Resolved, подтверждение и закрытие, а также реопен при возврате заявителя. Связан с контекстом support; агрегат — TicketEntity (+ переписка TicketMessageEntity, шаблоны ticket-template.md). Покрывает матрицу 4.1 (переписка тикета), 4.3 (шаблоны), 4.5 (жизненный цикл), 4.7 (база знаний).

Metadata

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

Актор

ПолеЗначение
РольСлужбаЗаботы — Оператор
Permissions / scopesupport.tickets.manage (ответ/статус); support.kb.view (ссылка на KB); support.templates.manage — не требуется для применения — platform-scope (ADR-0052)
TenantTenantId = 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 = ResolvedClosed (или InWork при реопене); FirstResponseAt проставлен один раз; при шаблоне UsageCount++; опубликованы ticket-message-posted.v1 (только Public) и ticket-status-changed.v1 на каждый переход; заявитель уведомлён в окне < 1 мин.

Триггер

ПолеЗначение
ИсточникUI button / API call
Триггерящий элементКарточка тикета в кабинете оператора (тред переписки, кнопки «Ответить»/«Решить»/«Закрыть»)

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

  1. Оператор открывает карточку тикета → GET /api/support/tickets/{id} (get-ticket) с тредом переписки (постранично).
  2. Пишет публичный ответ (опц. выбирает шаблон) → POST /api/support/tickets/{id}/messages (reply-ticket, Visibility = Public, XOR Body/TemplateId).
  3. Проверка: тикет не в Closed (иначе SUPPORT_TICKET_CLOSED); при шаблоне — рендер только whitelisted-плейсхолдеров (INV-TPL-3) и инкремент UsageCount шаблона; добавляется публичное сообщение оператора.
  4. INV-TKT-5: первый публичный ответ выставляет FirstResponseAt = now (SLA); MessagesCount++, LastMessageAt = now. Сообщение, счётчик UsageCount шаблона и событие ticket-message-posted.v1 (только Public, в outbox) фиксируются в одном согласованном изменении → 201.
  5. После получения нужных данных оператор решает обращение → POST /api/support/tickets/{id}/status (change-ticket-status, NewStatus = Resolved).
  6. Переход в Resolved (INV-TKT-4): требуется AssigneeUserId != null (иначе SUPPORT_TICKET_NO_ASSIGNEE), ResolvedAt = now; событие TicketStatusChanged (InWork → Resolved) в outbox.
  7. Заявитель подтверждает / истекает окно — оператор закрывает → change-ticket-status NewStatus = Closed (Resolved → Closed, ClosedAt = now); публикуется событие статуса.
  8. notifications (консюмер ticket-status-changed) уведомляет заявителя о решении/закрытии; фронт обновляет карточку.

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

A1. Внутренняя заметка (не видна заявителю)

  1. Оператор пишет reply-ticket с Visibility = Internal — добавляется внутренняя заметка (INV-MSG-2).
  2. FirstResponseAt не ставится (не публичный ответ), интеграционное событие не публикуется (Internal-заметки в шину не уходят).

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

A2. Реопен вернувшегося обращения

  1. Заявитель отвечает по закрытому/решённому тикету; оператор → change-ticket-status NewStatus = InWork.
  2. Resolved → InWork или Closed → InWork (INV-TKT-4/INV-TKT-7): ReopenedCount++, ResolvedAt/ClosedAt = null; событие статуса.

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

A3. Ссылка на статью базы знаний

  1. Оператор ищет статью → GET /api/support/kb-articles (list-kb-articles, FTS) и вставляет ссылку/выдержку в публичный ответ.
  2. Ответ отправляется как шаг 2 (Public); KB не мутируется (только чтение, аудитория Public/Internal).

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

A4. Ответ в закрытый тикет без реопена

  1. Оператор пытается ответить/сменить статус в тикете Status = Closed без реопена → SUPPORT_TICKET_CLOSED (409, INV-MSG-4/INV-TKT-7); сообщение не добавлено, событие не публикуется.
  2. Корректный путь — сначала реопен (A2), затем ответ.

Завершается ошибкой; направляет на реопен.

A5. Недопустимый переход статуса

  1. Resolve при AssigneeUserId == nullSUPPORT_TICKET_NO_ASSIGNEE (409): сначала взять/назначить (UC-SUP-001).
  2. Запрос перехода вне таблицы INV-TKT-4 (напр. New → Resolved, Closed → Resolved) → SUPPORT_TICKET_INVALID_TRANSITION (409); мутаций и события нет.
  3. NewStatus вне {InWork, Resolved, Closed} или нарушен XOR Body/TemplateIdVALIDATION_FAILED (400).

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

A6. Архивированный шаблон

  1. Оператор применяет шаблон в статусе ArchivedSUPPORT_TEMPLATE_ARCHIVED (409, INV-TPL-4); ответ не отправлен, UsageCount не меняется.

Завершается ошибкой; выбрать другой шаблон.

A7. Гонка оператор ↔ SLA-worker

  1. Оператор фиксирует ответ/переход одновременно с фоновым SLA-worker, помечающим SlaBreached/*DueAt: оптимистичная блокировка пропускает первого, проигравший → SUPPORT_TICKET_CONCURRENCY_CONFLICT (409) и повторяет на свежей версии (его решение не затирает чужое).
  2. При одновременных первых публичных ответах двух операторов FirstResponseAt (INV-TKT-5) проставляет только выигравший переход; второй метку не перезаписывает.

Возвращается к основному потоку на шаг 5 после перечитывания.

Постусловие

  • В support.ticket_messages добавлены сообщения (append-only); MessagesCount/LastMessageAt/FirstResponseAt обновлены.
  • TicketEntity.Status = ResolvedClosed (или InWork при реопене); ResolvedAt/ClosedAt/ReopenedCount согласованы; Version++.
  • Применённый шаблон: TicketTemplateEntity.UsageCount++. В outbox — ticket-message-posted.v1 (Public) и ticket-status-changed.v1.

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

КодHTTPУсловиеПоведение клиента
FORBIDDEN403нет support.tickets.manage / не назначенный без support.tickets.assignСкрыть действия
VALIDATION_FAILED400нарушен XOR Body/TemplateId; NewStatus вне {InWork,Resolved,Closed}Подсветить поля
SUPPORT_TICKET_NOT_FOUND404тикет не найден/скрыт/чужой tenantОбновить список
SUPPORT_TEMPLATE_ARCHIVED409шаблон архивирован (INV-TPL-4)Выбрать другой шаблон
SUPPORT_TICKET_CLOSED409ответ в Closed без реопена (INV-MSG-4/INV-TKT-7)Сначала реопен
SUPPORT_TICKET_INVALID_TRANSITION409недопустимый переход (INV-TKT-4)Показать допустимые действия
SUPPORT_TICKET_NO_ASSIGNEE409Resolved при AssigneeUserId == nullСначала взять/назначить
SUPPORT_TICKET_CONCURRENCY_CONFLICT409гонка оператор ↔ SLA-worker (оптимистичная блокировка)Перечитать и повторить

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

СобытиеКогда поднимаетсяSubscribers
TicketMessageAddedTicket.AddMessageсчётчики/FirstResponseAt, сборщик TicketMessagePostedEvent (Public)
TicketStatusChangedTicket.ChangeStatus (Resolve/Close/Reopen)таймлайн, сборщик TicketStatusChangedEvent

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

СобытиеTopicКогда публикуетсяConsumers
TicketMessagePostedEventpin.support.ticket-message-posted.v1публичный ответ (шаг 4) — контрактnotifications (заявитель: новый ответ)
TicketStatusChangedEventpin.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 (тогда)
AC1InWork-тикет без первого публичного ответаоператор пишет первый Public-ответFirstResponseAt проставлен один раз (INV-TKT-5, IsFirstResponse = true); повторные публичные ответы метку не меняют
AC2Активный (не Archived) шаблоноператор применяет шаблонрендерятся только whitelisted-плейсхолдеры (INV-TPL-3), UsageCount++ в том же изменении
AC3Оператор пишет Visibility = Internalзаметка добавленаticket-message-posted.v1 не публикуется; заметка не видна в public-API заявителя
AC4InWork-тикет, AssigneeUserId == nullоператор ставит ResolvedSUPPORT_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.

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

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

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