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

UC-SUP-003. Арбитраж конфликта через аудит

Назначение

Описывает разбор конфликта/жалобы (приоритет агента, конфликт бронирований, спор о комиссии, жалоба на контент) арбитром Службы заботы: регистрация дела, расследование по ссылкам на аудит/чаты/историю чужих сервисов (support не владеет их данными — только ссылается, INV-ARB), вынесение иммутабельного решения и информирование сторон событием. Связан с контекстом support; агрегат — ArbitrageCaseEntity (+ append-only ArbitrageNoteEntity). Покрывает матрицу 6.1 (регистрация), 6.2 (расследование), 6.3 (решение), 6.4 (информирование).

Metadata

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

Актор

ПолеЗначение
РольСлужбаЗаботы — Оператор/Арбитр (регистрация, расследование) + Супервизор (решение)
Permissions / scopeОператор — support.arbitrage.manage (register/investigate/add-note); Супервизор — support.arbitrage.decide (resolve/escalate); чтение — support.arbitrage.view; кросс-сервисный просмотр аудита — *.view (2.x) — все platform-scope (ADR-0052)
TenantTenantId = PIN CompanyId (единый tenant платформы)
Record-levelпо AssigneeUserId (арбитр ведёт назначенное дело); супервизор не ограничен

Предусловие

  • Есть конфликт между сторонами платформы; заявитель (ComplainantUserId) и ответчики (Respondents) известны.
  • Арбитр/супервизор аутентифицированы, несут соответствующие support.arbitrage.* permissions.
  • Для расследования доступны аудит-таймлайны чужих сервисов (crm/chessboard/rewards/messenger) — читаются их API в platform-scope.
  • ComplainantUserId не входит в список Respondents (заявитель не может быть ответчиком по своей жалобе); Respondents ≤ 50 без дублей.

Гарантируется после сценария: создано дело ARB-NNN (Registered → UnderInvestigation → Resolved/ Escalated → Closed); добавлены append-only заметки с типизированными AuditRefs; при завершении — иммутабельный Decision; опубликован arbitrage-resolved.v1; стороны информированы в окне < 1 мин.

Триггер

ПолеЗначение
ИсточникUI button / API call
Триггерящий элементКабинет арбитра: реестр дел и карточка дела; опц. кнопка «В арбитраж» из карточки тикета

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

  1. Арбитр регистрирует дело → POST /api/support/arbitrage-cases (register-arbitrage-case): Type, Title, Description, ComplainantUserId, Respondents, опц. SourceTicketId/RelatedDealId/RelatedFixationId.
  2. Регистрируется дело (INV-ARB-1): Status = Registered, Number = ARB-NNN, подпись ФИО заявителя из identity-lookup; поднимается доменное событие ArbitrageRegistered; дело фиксируется → 201.
  3. Арбитр берёт дело в работу (назначается AssigneeUserId) → переход Registered → UnderInvestigation (INV-ARB-1).
  4. Расследование по аудиту: арбитр читает аудит/чаты/историю чужих сервисов (2.x) и добавляет заметки со ссылками → POST /api/support/arbitrage-cases/{id}/notes (add-arbitrage-note): append-only заметка с AuditRefs (INV-ARB-5: только в Registered/UnderInvestigation).
  5. AuditRefs — типизированный jsonb ArbitrageAuditRefs (Service/EntityType/EntityId/ChangeAt?/Note?) ссылается на записи чужих сервисов; support данные не копирует, только фиксирует ссылку.
  6. Супервизор выносит решение → POST /api/support/arbitrage-cases/{id}/resolve (resolve-arbitrage-case): ArbitrageDecision (Outcome + Rationale + DecidedByUserId).
  7. Вынесение решения (INV-ARB-2): требуется AssigneeUserId != null и заполненный Decision; Status = Resolved, ResolvedAt = now, Decision иммутабелен; событие ArbitrageResolved в outbox → 200.
  8. notifications (консюмер arbitrage-resolved) информирует стороны (6.4); дело закрывается (Resolved → Closed).

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

A1. Эскалация администратору вместо решения

  1. Супервизор не может решить дело силами поддержки → POST /api/support/arbitrage-cases/{id}/escalate (escalate-arbitrage-case).
  2. Эскалация (INV-ARB-3): Status = Escalated, Decision.Outcome = EscalatedToAdmin, EscalatedToAdminAt/By; событие ArbitrageResolved (Escalated = true). Приём — администратор платформы (ADR-0053). См. UC-SUP-005.

Завершается статусом Escalated.

A2. Дело порождено из обращения

  1. Из карточки тикета (Category = Complaint) арбитр регистрирует дело с SourceTicketId; Ticket.ArbitrageCaseId связывается с делом.
  2. Далее — основной поток с шага 3.

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

A3. Ручные последствия решения — в owning-сервисах

  1. Решение требует смены статуса сделки/переназначения/корректировки комиссии — выполняется в crm/rewards через platform-scope permissions (UC-SUP-004).
  2. Факт фиксируется заметкой AuditRefs (ссылка на запись owning-сервиса); support чужие таблицы не пишет.

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

A4. Заметка/правка после решения

  1. Арбитр добавляет заметку в деле Status ∈ {Resolved, Escalated, Closed}SUPPORT_ARBITRAGE_NOTE_FORBIDDEN (409, INV-ARB-5): после решения заметки только для чтения.
  2. Попытка изменить Respondents/Related* после решения → SUPPORT_ARBITRAGE_LOCKED_AFTER_DECISION (INV-ARB-4): привязки иммутабельны.

Завершается ошибкой; карточка дела в режиме чтения.

A5. Решение без арбитра/решения или недопустимый переход

  1. Resolve при AssigneeUserId == null или пустом Decision (Outcome/Rationale/DecidedByUserId) → SUPPORT_ARBITRAGE_DECISION_REQUIRED (409, INV-ARB-2).
  2. Запрос перехода вне цепочки Registered → UnderInvestigation → (Resolved/Escalated) → Closed (напр. Registered → Resolved минуя расследование, повторный терминальный переход) → SUPPORT_ARBITRAGE_STATUS_TRANSITION (409, INV-ARB-1).

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

A6. Невалидные стороны/поля дела

  1. Title/Description/Body пустые или сверх длины; Respondents > 50 либо с дублями либо содержит ComplainantUserIdVALIDATION_FAILED (400); дело/заметка не создаются.

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

A7. Недоступен аудит-источник во время расследования

  1. Арбитр читает аудит/чат/историю crm/chessboard/rewards/messenger, а сосед недоступен → показывается частичная картина (id-ссылка без обогащения); расследование, добавление заметок и вынесение решения не блокируются (support ссылается, а не владеет данными, INV-ARB).
  2. Арбитр фиксирует известную AuditRef (Service/EntityType/EntityId), обогащение дорисуется при доступности соседа.

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

A8. Гонка арбитр ↔ супервизор

  1. Арбитр добавляет заметку одновременно с вынесением решения супервизором: оптимистичная блокировка пропускает первого; проигравший → CONCURRENCY_CONFLICT (409) и перечитывает дело. После фиксации Resolved/Escalated заметки закрыты (INV-ARB-5).

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

Постусловие

  • Создан ArbitrageCaseEntity (Registered → UnderInvestigation → Resolved/Escalated → Closed); Number = ARB-NNN.
  • Добавлены ArbitrageNoteEntity (append-only) с типизированными AuditRefs на аудит чужих сервисов.
  • При Resolve/Escalate — иммутабельный Decision; в outbox — ArbitrageResolvedEvent; стороны уведомлены.

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

КодHTTPУсловиеПоведение клиента
FORBIDDEN403нет support.arbitrage.manage/.decideСкрыть действия
VALIDATION_FAILED400пустой Title/Description/Body; Respondents > 50 или с дублямиПодсветить поля
SUPPORT_ARBITRAGE_NOT_FOUND404дело не найдено/скрытоОбновить реестр
SUPPORT_ARBITRAGE_STATUS_TRANSITION409недопустимый переход (INV-ARB-1)Показать допустимые действия
SUPPORT_ARBITRAGE_NOTE_FORBIDDEN409заметка после решения (INV-ARB-5)Заметки только для чтения
SUPPORT_ARBITRAGE_DECISION_REQUIRED409Resolve без AssigneeUserId/Decision (INV-ARB-2)Назначить арбитра/заполнить решение
SUPPORT_ARBITRAGE_ESCALATE_FORBIDDEN403эскалация без support.arbitrage.decide (INV-ARB-3)Скрыть эскалацию
CONCURRENCY_CONFLICT409гонка арбитр ↔ супервизор (оптимистичная блокировка)Перечитать и повторить

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

СобытиеКогда поднимаетсяSubscribers
ArbitrageRegisteredArbitrageCase.Registerсчётчики реестра, таймлайн
ArbitrageNoteAddedArbitrageCase.AddNoteтаймлайн расследования (интеграционного события нет)
ArbitrageResolvedArbitrageCase.Resolve/Escalateтаймлайн, сборщик ArbitrageResolvedEvent

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

СобытиеTopicКогда публикуетсяConsumers
ArbitrageResolvedEventpin.support.arbitrage-resolved.v1Resolve и Escalate (шаг 7 / A1) — контрактnotifications (информирование сторон, 6.4)

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

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

  • identity (синхронно/асинхронно). JWT + support.arbitrage.* permissions; кросс-сервисный просмотр аудита — *.view (2.x). Display-подпись заявителя — из реплики identity (ComplainantName, version-guard по IdentityComplainantVersion); реплика не пришла → карточка показывает ComplainantUserId без ФИО, расследование не блокируется.
  • Аудит-таймлайны соседей — только чтение по API в platform-scope. crm (сделки/фиксации), chessboard (объекты), rewards (начисления/комиссии), messenger (чаты). support данные не копирует, только фиксирует типизированную ссылку ArbitrageAuditRef (Service/EntityType/EntityId/ChangeAt?/Note?). Недоступность соседа → частичная картина, дело работоспособно (A7).

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

  • pin.support.arbitrage-resolved.v1 (ключ = CaseId, ContractVersion = 1, 3 партиции): терминальный исход дела — несёт Type/Outcome/Escalated/ComplainantUserId/RespondentUserIds/RelatedDealId?/ DecidedByUserId/DecidedAt (без Rationale/заметок — Internal). Публикуется на Resolve и Escalate атомарно с переходом, ровно один раз (Resolve и Escalate взаимоисключающи, INV-ARB-1). Потребитель notifications информирует стороны; закрытие (→ Closed) события не порождает.

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

  • Вынесение решения/эскалация → информирование сторон: асинхронно через outbox, < 1 мин. В промежутке стороны видят прежнее состояние дела; арбитр/супервизор — уже Resolved/Escalated.
  • Ручные последствия решения (A3, UC-SUP-004) выполняются в owning-сервисах и наблюдаются через их события (crm/rewards), не через arbitrage-resolved.

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

  • Повторный терминальный переход отклоняется SUPPORT_ARBITRAGE_STATUS_TRANSITION (INV-ARB-1) — событие информирования уходит ровно один раз.
  • Гонка арбитр ↔ супервизор разводится оптимистичной блокировкой (A8): проигравший → CONCURRENCY_CONFLICT.
  • Decision фиксируется иммутабельно; дубли arbitrage-resolved.v1 у notifications снимаются дедупликацией по MessageId (at-least-once).

Acceptance Criteria

#Given (дано)When (когда)Then (тогда)
AC1Известны заявитель и ответчики; арбитр с support.arbitrage.manageарбитр регистрирует делосоздан ArbitrageCaseEntity Registered с ARB-NNN; ФИО заявителя — реплика identity (или id при отставании)
AC2Дело Resolved/Escalated/Closedарбитр добавляет заметкуSUPPORT_ARBITRAGE_NOTE_FORBIDDEN (INV-ARB-5); в Registered/UnderInvestigation — заметка добавляется
AC3Заметка со ссылками на чужой аудитзаметка сохраненаAuditRefs — типизированный jsonb (Service/EntityType/EntityId), без копий чужих данных
AC4Дело UnderInvestigationсупервизор выносит Resolve без Decision/assigneeSUPPORT_ARBITRAGE_DECISION_REQUIRED (INV-ARB-2); при заполненном DecisionResolved, Decision иммутабелен (INV-ARB-4)
AC5Дело завершается (Resolve/Escalate)переход зафиксированarbitrage-resolved.v1 (ключ CaseId, v1) опубликован; notifications информирует стороны < 1 мин
AC6Тикет Category = Complaintарбитр регистрирует дело из тикетаTicket.ArbitrageCaseIdSourceTicketId связаны
AC7Аудит-источник (crm/chessboard/rewards/messenger) недоступенарбитр ведёт расследованиекартина частичная (id без обогащения), расследование/решение не блокируются

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

  • Латентность: регистрация/заметка/решение p95 < 500 мс; поиск дел по Number/Title — PostgreSQL ILIKE (низкий объём, без Elasticsearch). Объём арбитража мал (конфликты/жалобы).
  • Логи (без PII): arbitrage-registered (caseId, number, type), arbitrage-note-added (caseId, authorUserId, refsCount), arbitrage-resolved (caseId, outcome, escalated); Rationale/тексты заметок и ФИО сторон — не логируются.
  • Метрики: число дел по типам/исходам, время Registered → Resolved, доля эскалаций, конфликты арбитр ↔ супервизор, недоступность аудит-соседей при расследовании.
  • Доступ: регистрация/расследование/заметки — support.arbitrage.manage; решение/эскалация — support.arbitrage.decide (супервизор); чтение — support.arbitrage.view; всё platform-scope, record-level по AssigneeUserId.

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

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

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