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

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
KindAggregateRoot (дочерняя ArbitrageNoteEntity — Entity внутри агрегата)
Source of truthsupport
Таблицаsupport.arbitrage_cases (+ support.arbitrage_notes)
Tenant isolationTenantId = PIN CompanyId — платформенный инструмент; доступ по platform-scope, не по агентству/застройщику (ADR-0052)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор дела.
TenantIdGuidДаindex ix_arbitrage_tenant_idappPIN CompanyId (единый tenant платформы).
NumberlongДаunique ux_arbitrage_number; PG sequencedb (sequence)Человекочитаемый номер (ARB-NNN) для ссылок и поиска.
TitlestringДаvarchar(256), не пустое; участвует в поиске по номеру/заголовкуappЗаголовок дела.
TypeArbitrageTypeДа.HasConversion<string>(), varchar(32)appТип конфликта/жалобы (см. enum; 6.3 — приоритет агента).
StatusArbitrageStatusДа.HasConversion<string>(), varchar(32), index ix_arbitrage_statusappСтатус жизненного цикла (см. enum).
PriorityTicketPriorityДа.HasConversion<string>(), varchar(16), default NormalappПриоритет разбора (единый enum — ticket.md).
ComplainantUserIdGuidДаindex ix_arbitrage_complainant_user_id; логическая ссылка на identity.users(id)appКто подал (6.1); ключ fan-out консюмера identity.
ComplainantCompanyIdGuid?Нетindex where not nullappКомпания заявителя (контекст/фильтр).
RespondentsArbitrageParties (jsonb)Даtyped jsonb (см. модель ниже), default пустой списокappСтороны конфликта — ответчики (RespondentUserIds).
DescriptionstringДаvarchar(4000), не пустоеappСуть конфликта/жалобы (6.1).
SourceTicketIdGuid?НетFK fk_arbitrage_source_ticket_id → support.tickets(id) where not nullappОбращение-источник, если дело порождено из тикета.
RelatedDealIdGuid?Нетindex partial ix_arbitrage_related_deal_id where not null; логическая ссылка на crmappПривязка к сделке (6.1).
RelatedFixationIdGuid?Нетлогическая ссылка на crmappПривязка к фиксации (споры о приоритете агента).
RelatedPropertyObjectIdGuid?Нетлогическая ссылка на chessboardappПривязка к объекту недвижимости.
RelatedCompanyIdGuid?НетappПривязка к компании-стороне.
AssigneeUserIdGuid?Нетindex partial ix_arbitrage_assignee where not nullappНазначенный арбитр (record-level доступ). null — не взято.
DecisionArbitrageDecision? (owned)Нет (обяз. при Resolved/Escalated, INV-ARB-2/3)префикс колонок decision_ (см. VO ниже)app (супервизор)Вынесённое решение (6.3), иммутабельно после записи.
EscalatedToAdminAtDateTimeOffset?Нетapp (супервизор)Момент эскалации администратору (6.5).
EscalatedByUserIdGuid?НетappКто эскалировал.
ResolvedAtDateTimeOffset?НетappМомент перехода в Resolved.
ClosedAtDateTimeOffset?НетappМомент перехода в Closed.
ComplainantNameFullName (owned)Дасм. VO нижереплика identityФИО заявителя (display-реплика, PII).
ComplainantPhonestring?Нетvarchar(32); PII (маска в логах)реплика identityТелефон заявителя.
IdentityComplainantVersionlong?Нетconsumer pin.identity.users.v1Version-guard реплики ФИО/телефона (защита от out-of-order).
CreatedAtDateTimeOffsetДаindex ix_arbitrage_created_at descappРегистрация дела.
CreatedByUserIdGuid?НетappКто зарегистрировал (null — самозаявка/консюмер).
UpdatedAtDateTimeOffsetДаappМомент последнего изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?НетappSoft-delete (административно).
DeletedByUserIdGuid?Нет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_)

ПолеТипОбязательностьОграниченияКолонка
OutcomeArbitrageOutcomeДа.HasConversion<string>(), varchar(32)decision_outcome
RationalestringДаvarchar(4000), не пустоеdecision_rationale
DecidedByUserIdGuidДаdecision_decided_by_user_id
DecidedAtDateTimeOffsetДаdecision_decided_at

ComplainantNameFullName (owned)

Единый VO SDK, тот же контракт, что в crm/domain/client.md; колонки complainant_name_*.

ПолеТипОбязательностьОграниченияКолонка
FirstNamestringДаvarchar(128), не пустоеcomplainant_name_first_name
LastNamestring?Нетvarchar(128)complainant_name_last_name
MiddleNamestring?Нетvarchar(128)complainant_name_middle_name

Модели JSONB (IDocument, versioned)

ArbitrageParties (jsonb на агрегате, ContractVersion = 1)

ПолеТипОбязательностьОграниченияSince version
ItemsIReadOnlyList<Guid>ДаRespondentUserIds, <= 50, без дублейv1

ArbitrageAuditRefs (jsonb на ArbitrageNoteEntity, ContractVersion = 1)

Типизированные ссылки на аудит/чаты/историю чужих сервисов (6.2); элемент — ArbitrageAuditRef.

ПолеТипОбязательностьОграниченияSince version
ItemsIReadOnlyList<ArbitrageAuditRef>Да<= 50v1
Items[].ServicestringДаvarchar(64) — контекст-источникv1
Items[].EntityTypestringДаvarchar(64)v1
Items[].EntityIdGuidДаid записи в чужом сервисеv1
Items[].ChangeAtDateTimeOffset?Нетмомент изменения (если известен)v1
Items[].Notestring?Нетvarchar(512) — комментарий арбитраv1

Дочерняя сущность ArbitrageNoteEntity

Таблица support.arbitrage_notes. Заметки расследования (6.2), append-only; добавляются только в статусах Registered/UnderInvestigation (INV-ARB-5).

ПолеТипОбязательностьОграниченияИсточникОписание
IdGuidДаPKappИдентификатор заметки.
CaseIdGuidДаFK fk_arbitrage_notes_case_id → support.arbitrage_cases(id) cascade; index ix_arbitrage_notes_case_created (case_id,created_at)appРодительское дело.
TenantIdGuidДаindexиз делаДублируется для tenant-фильтра.
AuthorUserIdGuidДаappАвтор заметки (арбитр/оператор).
BodystringДаvarchar(4000), не пустоеappТекст заметки.
AuditRefsArbitrageAuditRefs (jsonb)Даtyped jsonb (см. модель выше), default пустой списокappСсылки на аудит/чаты/историю чужих сервисов (6.2).
CreatedAtDateTimeOffsetДаappМомент добавления.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
ArbitrageNoteEntityowned-childarbitrage_notes.case_idFilter c paging (карточка)cascade
TicketEntityreference (nullable)SourceTicketIdGetById при регистрации из тикетаrestrict
Заявитель / ответчикивнешняя логическаяComplainantUserId / Respondents.Itemsdisplay-данные API identity
Сделка / фиксация / объект недвижимости / компаниявнешняя логическаяRelatedDealId / RelatedFixationId / RelatedPropertyObjectId / RelatedCompanyIddisplay-данные API crm/chessboard

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-ARB-1Переходы: Registered → UnderInvestigation → (Resolved / Escalated) → Closed; иных переходов нетметод ChangeStatus(next, actor)SUPPORT_ARBITRAGE_STATUS_TRANSITION
INV-ARB-2Resolve требует AssigneeUserId is not null и заполненный Decision (Outcome + Rationale + DecidedByUserId); проставляет ResolvedAtметод Resolve(decision, actor)SUPPORT_ARBITRAGE_DECISION_REQUIRED
INV-ARB-3Escalate (→ Escalated) выставляет EscalatedToAdminAt/EscalatedByUserId, Decision.Outcome = EscalatedToAdmin; требует support.arbitrage.decide (супервизор)метод Escalate(rationale, actor)SUPPORT_ARBITRAGE_ESCALATE_FORBIDDEN
INV-ARB-4Respondents и 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); дело UnderInvestigationEscalatedToAdminAt/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/Escalated Respondents и 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-guard IdentityComplainantVersion: применяется только событие с версией строго новее сохранённой (защита от 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 = 1Resolve и EscalateCaseId, 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)
Партиционирование / retentionnone (низкий объём, ADR-0056)
Concurrencyоптимистическая блокировка: гонка арбитр ↔ супервизор (решение/эскалация) — правка не должна затирать чужую
Permissions / PIIsupport.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 фиксируется иммутабельно

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

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

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