Entity / Aggregate: ArbitrageCaseEntity (+ ArbitrageNoteEntity)
Дом полей агрегата «Арбитражное дело» контекста support (greenfield, матрица раздел 6:
6.1 регистрация, 6.2 расследование, 6.3 решение, 6.4 информирование, 6.5 эскалация). Все проекции
(карточка дела, списки/очередь арбитра, payload события arbitrage-resolved) ссылаются сюда и
описывают только дельту, а не переописывают поля.
Назначение
ArbitrageCaseEntity — арбитражное дело по конфликту/жалобе между сторонами платформы (приоритет агента,
конфликт бронирований, спор о комиссии, жалоба на контент). Регистрируется заявителем/оператором, опционально
из обращения (ticket.md, SourceTicketId), расследуется арбитром через ссылки на аудит/чаты/историю
чужих сервисов (6.2, только ссылки — support не владеет их данными), завершается иммутабельным решением (6.3)
или эскалацией администратору (6.5); информирование сторон — интеграционным событием (6.4). Aggregate root:
заметки расследования — дочерняя append-only ArbitrageNoteEntity. Заявитель, ответчики,
сделка/фиксация/объект недвижимости/компания — внешние логические ссылки.
Классификация
| Поле | Значение |
|---|---|
| Контекст | support |
| Kind | AggregateRoot (дочерняя ArbitrageNoteEntity — Entity внутри агрегата) |
| Source of truth | support |
| Таблица | support.arbitrage_cases (+ support.arbitrage_notes) |
| Tenant isolation | TenantId = PIN CompanyId — платформенный инструмент; доступ по platform-scope, не по агентству/застройщику (ADR-0052) |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор дела. |
TenantId | Guid | Да | index ix_arbitrage_tenant_id | app | PIN CompanyId (единый tenant платформы). |
Number | long | Да | unique ux_arbitrage_number; PG sequence | db (sequence) | Человекочитаемый номер (ARB-NNN) для ссылок и поиска. |
Title | string | Да | varchar(256), не пустое; участвует в поиске по номеру/заголовку | app | Заголовок дела. |
Type | ArbitrageType | Да | .HasConversion<string>(), varchar(32) | app | Тип конфликта/жалобы (см. enum; 6.3 — приоритет агента). |
Status | ArbitrageStatus | Да | .HasConversion<string>(), varchar(32), index ix_arbitrage_status | app | Статус жизненного цикла (см. enum). |
Priority | TicketPriority | Да | .HasConversion<string>(), varchar(16), default Normal | app | Приоритет разбора (единый enum — ticket.md). |
ComplainantUserId | Guid | Да | index ix_arbitrage_complainant_user_id; логическая ссылка на identity.users(id) | app | Кто подал (6.1); ключ fan-out консюмера identity. |
ComplainantCompanyId | Guid? | Нет | index where not null | app | Компания заявителя (контекст/фильтр). |
Respondents | ArbitrageParties (jsonb) | Да | typed jsonb (см. модель ниже), default пустой список | app | Стороны конфликта — ответчики (RespondentUserIds). |
Description | string | Да | varchar(4000), не пустое | app | Суть конфликта/жалобы (6.1). |
SourceTicketId | Guid? | Нет | FK fk_arbitrage_source_ticket_id → support.tickets(id) where not null | app | Обращение-источник, если дело порождено из тикета. |
RelatedDealId | Guid? | Нет | index partial ix_arbitrage_related_deal_id where not null; логическая ссылка на crm | app | Привязка к сделке (6.1). |
RelatedFixationId | Guid? | Нет | логическая ссылка на crm | app | Привязка к фиксации (споры о приоритете агента). |
RelatedPropertyObjectId | Guid? | Нет | логическая ссылка на chessboard | app | Привязка к объекту недвижимости. |
RelatedCompanyId | Guid? | Нет | — | app | Привязка к компании-стороне. |
AssigneeUserId | Guid? | Нет | index partial ix_arbitrage_assignee where not null | app | Назначенный арбитр (record-level доступ). null — не взято. |
Decision | ArbitrageDecision? (owned) | Нет (обяз. при Resolved/Escalated, INV-ARB-2/3) | префикс колонок decision_ (см. VO ниже) | app (супервизор) | Вынесённое решение (6.3), иммутабельно после записи. |
EscalatedToAdminAt | DateTimeOffset? | Нет | — | app (супервизор) | Момент эскалации администратору (6.5). |
EscalatedByUserId | Guid? | Нет | — | app | Кто эскалировал. |
ResolvedAt | DateTimeOffset? | Нет | — | app | Момент перехода в Resolved. |
ClosedAt | DateTimeOffset? | Нет | — | app | Момент перехода в Closed. |
ComplainantName | FullName (owned) | Да | см. VO ниже | реплика identity | ФИО заявителя (display-реплика, PII). |
ComplainantPhone | string? | Нет | varchar(32); PII (маска в логах) | реплика identity | Телефон заявителя. |
IdentityComplainantVersion | long? | Нет | — | consumer pin.identity.users.v1 | Version-guard реплики ФИО/телефона (защита от out-of-order). |
CreatedAt | DateTimeOffset | Да | index ix_arbitrage_created_at desc | app | Регистрация дела. |
CreatedByUserId | Guid? | Нет | — | app | Кто зарегистрировал (null — самозаявка/консюмер). |
UpdatedAt | DateTimeOffset | Да | — | app | Момент последнего изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший. |
DeletedAt | DateTimeOffset? | Нет | — | app | Soft-delete (административно). |
DeletedByUserId | Guid? | Нет | — | app | Кто скрыл. |
Enum ArbitrageType
| Значение | Описание |
|---|---|
AgentPriorityConflict | Спор о приоритете агента (6.3). |
BookingConflict | Конфликт бронирований. |
CommissionDispute | Спор о комиссии. |
ContentComplaint | Жалоба на контент/каталог. |
Other | Прочее. |
Enum ArbitrageStatus
| Значение | Описание |
|---|---|
Registered | Дело зарегистрировано (6.1), арбитр не начал расследование. |
UnderInvestigation | Идёт расследование (6.2), заметки/аудит-ссылки. |
Resolved | Решение вынесено (6.3), ожидает закрытия. |
Escalated | Эскалировано администратору (6.5). |
Closed | Терминальный: дело закрыто. |
Enum ArbitrageOutcome
| Значение | Описание |
|---|---|
FavorComplainant | Решение в пользу заявителя. |
FavorRespondent | Решение в пользу ответчика. |
SplitResolution | Смешанное/частичное решение. |
NoViolation | Нарушение не выявлено. |
EscalatedToAdmin | Передано администратору (проставляется при эскалации, INV-ARB-3). |
Value objects
ArbitrageDecision (owned, префикс decision_)
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
Outcome | ArbitrageOutcome | Да | .HasConversion<string>(), varchar(32) | decision_outcome |
Rationale | string | Да | varchar(4000), не пустое | decision_rationale |
DecidedByUserId | Guid | Да | — | decision_decided_by_user_id |
DecidedAt | DateTimeOffset | Да | — | decision_decided_at |
ComplainantName — FullName (owned)
Единый VO SDK, тот же контракт, что в crm/domain/client.md; колонки complainant_name_*.
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
FirstName | string | Да | varchar(128), не пустое | complainant_name_first_name |
LastName | string? | Нет | varchar(128) | complainant_name_last_name |
MiddleName | string? | Нет | varchar(128) | complainant_name_middle_name |
Модели JSONB (IDocument, versioned)
ArbitrageParties (jsonb на агрегате, ContractVersion = 1)
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|---|---|---|---|
Items | IReadOnlyList<Guid> | Да | RespondentUserIds, <= 50, без дублей | v1 |
ArbitrageAuditRefs (jsonb на ArbitrageNoteEntity, ContractVersion = 1)
Типизированные ссылки на аудит/чаты/историю чужих сервисов (6.2); элемент — ArbitrageAuditRef.
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|---|---|---|---|
Items | IReadOnlyList<ArbitrageAuditRef> | Да | <= 50 | v1 |
Items[].Service | string | Да | varchar(64) — контекст-источник | v1 |
Items[].EntityType | string | Да | varchar(64) | v1 |
Items[].EntityId | Guid | Да | id записи в чужом сервисе | v1 |
Items[].ChangeAt | DateTimeOffset? | Нет | момент изменения (если известен) | v1 |
Items[].Note | string? | Нет | varchar(512) — комментарий арбитра | v1 |
Дочерняя сущность ArbitrageNoteEntity
Таблица support.arbitrage_notes. Заметки расследования (6.2), append-only; добавляются только в статусах Registered/UnderInvestigation (INV-ARB-5).
| Поле | Тип | Обязательность | Ограничения | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор заметки. |
CaseId | Guid | Да | FK fk_arbitrage_notes_case_id → support.arbitrage_cases(id) cascade; index ix_arbitrage_notes_case_created (case_id,created_at) | app | Родительское дело. |
TenantId | Guid | Да | index | из дела | Дублируется для tenant-фильтра. |
AuthorUserId | Guid | Да | — | app | Автор заметки (арбитр/оператор). |
Body | string | Да | varchar(4000), не пустое | app | Текст заметки. |
AuditRefs | ArbitrageAuditRefs (jsonb) | Да | typed jsonb (см. модель выше), default пустой список | app | Ссылки на аудит/чаты/историю чужих сервисов (6.2). |
CreatedAt | DateTimeOffset | Да | — | app | Момент добавления. |
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
ArbitrageNoteEntity | owned-child | arbitrage_notes.case_id | Filter c paging (карточка) | cascade |
TicketEntity | reference (nullable) | SourceTicketId | GetById при регистрации из тикета | restrict |
| Заявитель / ответчики | внешняя логическая | ComplainantUserId / Respondents.Items | display-данные API identity | — |
| Сделка / фиксация / объект недвижимости / компания | внешняя логическая | RelatedDealId / RelatedFixationId / RelatedPropertyObjectId / RelatedCompanyId | display-данные API crm/chessboard | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-ARB-1 | Переходы: Registered → UnderInvestigation → (Resolved / Escalated) → Closed; иных переходов нет | метод ChangeStatus(next, actor) | SUPPORT_ARBITRAGE_STATUS_TRANSITION |
| INV-ARB-2 | Resolve требует AssigneeUserId is not null и заполненный Decision (Outcome + Rationale + DecidedByUserId); проставляет ResolvedAt | метод Resolve(decision, actor) | SUPPORT_ARBITRAGE_DECISION_REQUIRED |
| INV-ARB-3 | Escalate (→ Escalated) выставляет EscalatedToAdminAt/EscalatedByUserId, Decision.Outcome = EscalatedToAdmin; требует support.arbitrage.decide (супервизор) | метод Escalate(rationale, actor) | SUPPORT_ARBITRAGE_ESCALATE_FORBIDDEN |
| INV-ARB-4 | Respondents и Related*-привязки иммутабельны после Resolved/Escalated | сеттеры/командные методы | SUPPORT_ARBITRAGE_LOCKED_AFTER_DECISION |
| INV-ARB-5 | Заметки добавляются только в Registered/UnderInvestigation; после решения — только чтение | метод AddNote(note, actor) | SUPPORT_ARBITRAGE_NOTE_FORBIDDEN |
Предусловия и постусловия переходов
| Переход | Предусловие | Постусловие (гарантируется) |
|---|---|---|
Register (→ Registered, 6.1) | заявитель существует; Title/Description непусты; при регистрации из тикета — SourceTicketId указывает на существующий тикет | выдан Number (ARB-NNN), Status = Registered, AssigneeUserId = null, поднято доменное ArbitrageRegistered (внутренние счётчики/таймлайн) |
StartInvestigation (Registered → UnderInvestigation, 6.2) | актор — оператор (support.arbitrage.manage); дело Registered | арбитр берёт дело в работу — в этом переходе проставляется AssigneeUserId (record-level владелец); открыто добавление заметок/аудит-ссылок (INV-ARB-5) |
Resolve (UnderInvestigation → Resolved, 6.3) | AssigneeUserId is not null; заполнен Decision (Outcome+Rationale+DecidedByUserId, INV-ARB-2); актор — супервизор (support.arbitrage.decide) | Decision записан иммутабельно, ResolvedAt проставлен, Respondents/Related* заморожены (INV-ARB-4), опубликовано ArbitrageResolvedKafkaEvent (Escalated = false) атомарно с переходом |
Escalate (UnderInvestigation → Escalated, 6.5) | актор — супервизор (INV-ARB-3); дело UnderInvestigation | EscalatedToAdminAt/EscalatedByUserId заданы, Decision.Outcome = EscalatedToAdmin, опубликовано ArbitrageResolvedKafkaEvent (Escalated = true); дальнейший приём — администратор платформы (ADR-0053) |
Close (Resolved/Escalated → Closed) | дело в терминально-подтверждаемом статусе | ClosedAt проставлен; Kafka-событие не публикуется (пост-финал); дело иммутабельно, заметки — только чтение |
Resolve и Escalate взаимоисключающи: как только дело перешло в Resolved либо Escalated, повторный терминальный переход отклоняется (INV-ARB-1, SUPPORT_ARBITRAGE_STATUS_TRANSITION) — событие информирования сторон уходит ровно один раз. Иных переходов, кроме перечисленных в INV-ARB-1, нет; любой запрос на недопустимый переход → SUPPORT_ARBITRAGE_STATUS_TRANSITION.
Конкурентность и идемпотентность
- Гонка Resolve ↔ Escalate. Два супервизора, одновременно выносящие решение и эскалирующие одно дело,
сериализуются оптимистичной блокировкой: побеждает первый зафиксировавший терминальный переход,
DecisionиArbitrageResolvedKafkaEventфиксируются ровно один раз; проигравший видитSUPPORT_ARBITRAGE_STATUS_TRANSITION(дело уже терминально) и его правка не затирает чужую. То же для двух Resolve или двух Escalate. - Заморозка после решения. После
Resolved/EscalatedRespondentsиRelated*иммутабельны (INV-ARB-4,SUPPORT_ARBITRAGE_LOCKED_AFTER_DECISION), заметки — только чтение (INV-ARB-5,SUPPORT_ARBITRAGE_NOTE_FORBIDDEN); запоздавшая правка от проигравшего отклоняется этими инвариантами. - Повторная регистрация. Естественного ключа дедупликации у регистрации нет: повторная отправка формы
создаёт новое дело с новым
Number. Дубликаты снимаются организационно (арбитр закрывает лишнее дело какOther/спам); привязка кSourceTicketIdне уникальна — из одного тикета допустимо более одного дела (разные предметы спора). Публикация терминального события при этом остаётся идемпотентной на потребителе поMessageId.
Валидации полей
| Поле | Правило |
|---|---|
Title | непустое, <= 256; участвует в поиске по номеру/заголовку |
Description | непустое, <= 4000 |
Type / Priority | значение ∈ соответствующего enum (иначе VALIDATION_FAILED) |
Respondents.Items (RespondentUserIds) | <= 50, без дублей; ComplainantUserId в списке ответчиков не присутствует (заявитель не может быть ответчиком по собственной жалобе); допускается пустой список |
Decision.Rationale | непустое, <= 4000; обязателен при Resolve (INV-ARB-2) |
AuditRefs.Items (заметка) | <= 50; каждый элемент — непустые Service/EntityType + EntityId; Note <= 512 |
Body (заметка) | непустое, <= 4000 |
Кросс-сервисные ожидания (реплики)
- Реплика заявителя из
identity.ComplainantName/ComplainantPhone— display-реплика, наполняется консюмеромpin.identity.users.v1(дом консюмера — ../consumers/identity-user-upserted.md); не source of truth. Version-guardIdentityComplainantVersion: применяется только событие с версией строго новее сохранённой (защита от out-of-order); устаревшее/повторное — игнорируется без ошибки. - Реплика ещё не пришла (дело зарегистрировано раньше, чем потреблено событие пользователя): карточка показывает
ComplainantUserIdбез ФИО/телефона — деградация display, регистрация/расследование/решение при этом не блокируются; ФИО дорисуется по факту consume (окно eventual-consistency — секунды). - Related-ссылки (
RelatedDealId/RelatedFixationId/RelatedPropertyObjectId/RelatedCompanyId) — логические, без FK: display-обогащение тянется синхронно изcrm/chessboardпо требованию карточки; недоступность/отсутствие соседа → показывается id-ссылка без обогащения, дело остаётся работоспособным.
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
ArbitrageRegistered / ArbitrageResolved | доменное | Регистрация / вынесение решения (или эскалация) | внутренние подписчики (счётчики, таймлайн) |
ArbitrageResolvedEvent | интеграционное, topic pin.support.arbitrage-resolved.v1, ContractVersion = 1 | Resolve и Escalate | CaseId, Number, TenantId, Type, Outcome, ComplainantUserId, RespondentUserIds, RelatedDealId?, Escalated (bool), DecidedAt; потребитель: notifications (информирование сторон, 6.4); контракт — ../events/arbitrage-resolved.md |
Интеграционное событие публикуется в одном согласованном изменении со сменой состояния агрегата (Resolve/Escalate).
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK; ux_arbitrage_number unique; ix_arbitrage_tenant_id; ix_arbitrage_status; ix_arbitrage_assignee partial where assignee_user_id is not null; ix_arbitrage_complainant_user_id (fan-out консюмера identity); ix_arbitrage_related_deal_id partial where not null; ix_arbitrage_created_at desc; arbitrage_notes: PK, ix_arbitrage_notes_case_created (case_id,created_at) |
| Поиск | арбитраж — низкий объём (конфликты/жалобы), отдельного Elasticsearch-индекса нет: поиск по Number/Title — case-insensitive ILIKE (опц. pg_trgm) в PostgreSQL (ADR-0056; две поисковые поверхности support на Elasticsearch — тикеты и база знаний, ADR-0059) |
| Партиционирование / retention | none (низкий объём, ADR-0056) |
| Concurrency | оптимистическая блокировка: гонка арбитр ↔ супервизор (решение/эскалация) — правка не должна затирать чужую |
| Permissions / PII | support.arbitrage.view (наблюдатель) / support.arbitrage.manage (оператор — register/investigate/add-note) / support.arbitrage.decide (супервизор — resolve/escalate) — platform-scope (ADR-0052). Ручные последствия решения (смена статуса сделки / переназначение / корректировка комиссии) выполняются в crm/rewards через выданные platform-scope perms и здесь не дублируются (AuditRefs фиксирует факт). PII: ComplainantName/ComplainantPhone — маска в логах, наружу — по permission. |
| Audit | отметки создания/изменения/удаления (кто и когда), soft-delete; заметки — append-only; Decision фиксируется иммутабельно |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: TicketEntity
- API (8): GET /api/support/arbitrage-cases — Очередь арбитражных дел (фильтры), GET /api/support/arbitrage-cases/{id} — Карточка арбитражного дела, GET /api/support/dashboard — Дашборд обращений Службы заботы, GET /api/support/tickets/{id} — Карточка обращения (GetTicket), POST /api/support/arbitrage-cases — Регистрация арбитражного дела, POST /api/support/arbitrage-cases/{id}/escalate — Эскалация дела администратору, POST /api/support/arbitrage-cases/{id}/notes — Заметка расследования, POST /api/support/arbitrage-cases/{id}/resolve — Вынести решение по делу
- Use Cases (3): UC-SUP-003. Арбитраж конфликта через аудит, UC-SUP-004. Ручное вмешательство через permissions, UC-SUP-005. Эскалация администратору
- События (2): События сервиса support: карта топиков и решения об интеграциях, Event Contract: pin.support.arbitrage-resolved (ArbitrageResolvedKafkaEvent)
- Консюмеры (1): Consumer: Support Identity User Sync
- Дашборды (1): Панель Наблюдаемости: support-overview (эксплуатация)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): Support Service
Связанные документы
- ticket.md (
SourceTicketId); событие — ../events/arbitrage-resolved.md; сценарий — UC-SUP-003; README. - Tenancy — ADR-0052; эскалация администратору — ADR-0053; масштаб/фон — ADR-0056.