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

Entity / Aggregate: TicketEntity

Дом полей агрегата «Обращение (тикет)» контекста support (greenfield, матрица полей 4.4/4.5). Все проекции (карточка тикета, списки очередей/оператора, payload интеграционных событий) ссылаются сюда и описывают только дельту, а не переописывают поля.

Назначение

TicketEntity — обращение пользователя в Службу заботы ПИН: единица работы оператора с жизненным циклом, маршрутизацией по очередям (ticket-queue.md) и SLA. Aggregate root: переписка обращения — дочерняя TicketMessageEntity (ticket-message.md, owned-child). Из тикета может быть порождён арбитражный кейс (arbitrage-case.md). Заявитель, сделка и объект недвижимости — внешние логические ссылки (данные не грузятся в агрегат). SLA-дедлайны и флаг просрочки ведёт фоновый worker (ADR-0056), первый публичный ответ и статусы — оператор; гонка между ними разводится оптимистичной блокировкой.

Классификация

ПолеЗначение
Контекстsupport
KindAggregateRoot (дочерняя TicketMessageEntity — переписка внутри агрегата)
Source of truthsupport
Таблицаsupport.tickets (+ support.ticket_messages)
Tenant isolationTenantId = PIN CompanyId — платформенный инструмент; доступ по platform-scope, не по агентству/застройщику (ADR-0052)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор тикета.
TenantIdGuidДаindex ix_tickets_tenant_idappPIN CompanyId (единый tenant платформы).
NumberlongДаunique ux_tickets_number; PG sequencedb (sequence)Человекочитаемый номер (SUP-NNN) для ссылок и поиска.
SubjectstringДаvarchar(256), не пустое; индексируется в Elasticsearch (support-tickets)app (заявитель/оператор)Тема обращения.
CategoryTicketCategoryДа.HasConversion<string>(), varchar(32)appТематика (анализ тематик, п. 8.4).
PriorityTicketPriorityДа.HasConversion<string>(), varchar(16), default NormalappПриоритет (влияет на SLA-дедлайны).
StatusTicketStatusДа.HasConversion<string>(), varchar(16), index ix_tickets_statusappСтатус жизненного цикла (см. enum).
QueueIdGuidДаFK fk_tickets_queue_id → support.ticket_queues(id), index ix_tickets_queue_idapp (маршрутизация)Текущая очередь.
AssigneeUserIdGuid?Нетindex partial ix_tickets_assignee where assignee_user_id is not nullappНазначенный оператор (record-level доступ). null — не взят.
ChannelTicketChannelДа.HasConversion<string>(), varchar(16)appКанал поступления (см. enum). Иммутабельно.
RequesterUserIdGuidДаindex ix_tickets_requester_user_id; логическая ссылка на identity.users(id)appЗаявитель; ключ fan-out консюмера identity.
RequesterCompanyIdGuid?Нетindex where not nullappКомпания заявителя (контекст/фильтр).
RequesterRolestring?Нетvarchar(64)appРоль заявителя на момент обращения (снапшот).
RequesterNameFullName (owned)Дасм. VO нижереплика identityФИО заявителя (display-реплика).
RequesterPhonestring?Нетvarchar(32); PII (маска в логах)реплика identityТелефон заявителя.
RequesterEmailstring?Нетvarchar(256); PII (маска в логах)реплика identityEmail заявителя.
IdentityRequesterVersionlong?Нетconsumer pin.identity.users.v1Version-guard реплики ФИО/контактов (защита от out-of-order обновлений).
RelatedDealIdGuid?Нетindex where not null; логическая ссылка на crmappСвязанная сделка (контекст обращения).
RelatedPropertyObjectIdGuid?Нетлогическая ссылка на chessboardappСвязанный объект недвижимости.
RelatedCompanyIdGuid?НетappСвязанная компания (предмет обращения).
ArbitrageCaseIdGuid?НетFK fk_tickets_arbitrage_case_id → support.arbitrage_cases(id) where not nullappПорождённый из тикета арбитражный кейс, если есть.
MessagesCountintДаdefault 0, >= 0app (domain event переписки)Счётчик сообщений.
LastMessageAtDateTimeOffset?Нетindex ix_tickets_last_message_at descappМомент последнего сообщения (сортировка списков).
FirstResponseAtDateTimeOffset?НетappПервый публичный ответ оператора (SLA, п. 8.1); ставится один раз.
FirstResponseDueAtDateTimeOffsetДаapp (политика SLA по Priority при создании)Дедлайн первого ответа; сканируется worker.
ResolutionDueAtDateTimeOffset?Нетapp (политика SLA по Priority)Дедлайн решения.
SlaBreachedboolДаdefault falseworkerПризнак нарушения SLA (выставляет worker).
ResolvedAtDateTimeOffset?НетappМомент перехода в Resolved.
ClosedAtDateTimeOffset?НетappМомент перехода в Closed.
ReopenedCountintДаdefault 0, >= 0appЧисло переоткрытий (реопен из Closed/Resolved).
EscalatedToAdminAtDateTimeOffset?Нетapp (супервизор)Момент эскалации на администратора.
EscalatedByUserIdGuid?НетappКто эскалировал.
CreatedAtDateTimeOffsetДаindex ix_tickets_created_at descappСоздание обращения.
CreatedByUserIdGuid?НетappКто создал (null — WebForm-самозаявка/консюмер).
UpdatedAtDateTimeOffsetДаappМомент последнего изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?НетappSoft-delete (административно / спам).
DeletedByUserIdGuid?НетappКто скрыл.

Enum TicketStatus

ЗначениеОписание
NewСоздан, ещё не взят в работу.
InWorkОператор ведёт обращение (AssigneeUserId задан).
ResolvedОператор решил обращение; ожидает подтверждения/закрытия.
ClosedТерминальный: закрыт (решение подтверждено, спам или реопен-источник).

Enum TicketPriority

ЗначениеОписание
LowНизкий.
NormalОбычный (default).
HighВысокий.
UrgentСрочный (наименьшие SLA-дедлайны).

Enum TicketCategory

ЗначениеОписание
AccountУчётная запись / доступ.
DealВопрос по сделке.
BookingБронирование.
PaymentОплата / расчёты.
ContentКонтент / каталог.
TechnicalТехническая проблема.
ComplaintЖалоба (кандидат в арбитраж).
OtherПрочее.

Enum TicketChannel

ЗначениеОписание
WebFormВеб-форма заявителя.
OperatorЗаведён оператором вручную.
ExternalDeskИмпорт из внешнего деска.

Value objects

FullName (owned)

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

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

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

JSONB-модели не используются: вложения и типизированные тела сообщений — в ticket-message.md.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
TicketQueueEntityreferenceQueueIdзагрузка при создании / переводе в очередьrestrict
TicketMessageEntityowned-childticket_messages.ticket_idвыборка с пагинацией (карточка)cascade
ArbitrageCaseEntityreference (nullable)ArbitrageCaseIdзагрузка при открытии карточки арбитражаrestrict
Заявитель / сделка / объект недвижимостивнешняя логическаяRequesterUserId / RelatedDealId / RelatedPropertyObjectIddisplay-данные API identity/crm/chessboard

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

ИнвариантУсловиеГде проверяетсяОшибка
INV-TKT-1Создание: Status = New, AssigneeUserId = null; QueueId по маршрутизации (Category → очередь, иначе default-очередь TenantId)при создании тикета (маршрутизация в очередь)SUPPORT_TICKET_QUEUE_NOT_FOUND
INV-TKT-2Взятие в работу: только New → InWork оператором; AssigneeUserId = actorпри взятии тикета в работуSUPPORT_TICKET_TAKE_FORBIDDEN
INV-TKT-3Назначение: супервизор задаёт AssigneeUserId в статусах New/InWorkпри назначении оператораSUPPORT_TICKET_ASSIGN_FORBIDDEN
INV-TKT-4Переходы: New→InWork→Resolved→Closed; Resolved→InWork/Closed; Closed→InWork (реопен, ReopenedCount++); New→Closed (спам). Переход в Resolved требует AssigneeUserId is not nullпри смене статусаSUPPORT_TICKET_STATUS_TRANSITION / SUPPORT_TICKET_ASSIGNEE_REQUIRED
INV-TKT-5FirstResponseAt ставится один раз — при первом публичном (не Internal) сообщении операторапри добавлении публичного сообщения оператора
INV-TKT-6SLA-переходы (SlaBreached, *DueAt) выставляет только фоновый SLA-worker (идемпотентно)фоновый SLA-worker (ADR-0056)
INV-TKT-7Терминальный Closed не мутируется, кроме перехода в InWork (реопен)во всех операциях изменения тикетаSUPPORT_TICKET_ALREADY_CLOSED

Предусловия и постусловия переходов

ПереходИнициатор / условиеПредусловиеПостусловие (гарантируется)
Create ([*] → New)заявитель (веб-форма) / оператор (on-behalf)заявитель существует; Subject и первое сообщение непусты; для Category найдена активная маршрут-очередь или default (INV-Q-5)выдан Number (SUP-NNN), Status = New, AssigneeUserId = null, QueueId по маршрутизации, FirstResponseDueAt/ResolutionDueAt рассчитаны политикой SLA от Priority, MessagesCount = 1; опубликован TicketCreatedKafkaEvent (pin.support.ticket-created.v1) атомарно
Take / Assign (New → InWork)оператор (INV-TKT-2) / супервизор (INV-TKT-3)тикет New; для take — не занят другим оператором; для assign — актор имеет support.tickets.assignAssigneeUserId задан (record-level владелец); опубликован TicketStatusChangedKafkaEvent
Resolve (InWork → Resolved)оператор-владелец / супервизорAssigneeUserId is not null (INV-TKT-4, иначе SUPPORT_TICKET_ASSIGNEE_REQUIRED)ResolvedAt проставлен; событие status-changed опубликовано
Reopen (Resolved → InWork / Closed → InWork)оператор/супервизор или авто-реопен при публичном ответе заявителятикет Resolved или ClosedStatus = InWork; из ClosedReopenedCount++; событие status-changed опубликовано
Close (Resolved → Closed / New → Closed)оператор/супервизорResolved (решение подтверждено) или New (спам)ClosedAt проставлен; терминальный (INV-TKT-7) — дальнейшие мутации, кроме reopen, отклоняются SUPPORT_TICKET_ALREADY_CLOSED; событие status-changed опубликовано

Любой запрос на переход вне таблицы (напр. New → Resolved, Closed → Resolved) отклоняется SUPPORT_TICKET_STATUS_TRANSITION (INV-TKT-4). Интеграционное событие смены статуса уходит ровно один раз на переход, атомарно с мутацией; повторный вызов той же операции на уже целевом статусе — no-op (см. идемпотентность ниже), нового события не порождает.

Кросс-сервисные ожидания (реплики) и конкурентность

  • Реплика заявителя из identity. RequesterName/RequesterPhone/RequesterEmail — display-реплика, наполняется консюмером pin.identity.users.v1 (дом консюмера — ../consumers/identity-user-upserted.md); не source of truth. Version-guard IdentityRequesterVersion: применяется только событие с версией строго новее сохранённой (защита от out-of-order); устаревшее/повторное — игнорируется без ошибки. Fan-out по RequesterUserId обновляет ФИО/контакты сразу на всех тикетах заявителя.
  • Реплика ещё не пришла (тикет создан раньше, чем потреблено событие пользователя): карточка/списки показывают RequesterUserId без ФИО/телефона — деградация display; создание, маршрутизация, взятие в работу и ответы при этом не блокируются; ФИО дорисуется по факту consume (окно eventual-consistency — секунды). Поисковая проекция (support-tickets) обновляет RequesterName/RequesterPhone тем же событием.
  • Related-ссылки (RelatedDealId/RelatedPropertyObjectId/RelatedCompanyId) — логические, без FK: display-обогащение тянется синхронно из crm/chessboard по требованию карточки; недоступность/отсутствие соседа → показывается id-ссылка без обогащения, тикет остаётся работоспособным.
  • Гонка SLA-worker ↔ оператор. SlaBreached и *DueAt выставляет только фоновый worker (INV-TKT-6); статусы, AssigneeUserId и FirstResponseAt — оператор. Параллельная правка разводится оптимистичной блокировкой: побеждает первый зафиксировавший, проигравший повторяет операцию на свежей версии (его прежнее решение не затирает чужое). FirstResponseAt ставится один раз (INV-TKT-5): при одновременных первых публичных ответах двух операторов метку проставляет только выигравший переход, второй её не перезаписывает.
  • Идемпотентность операций. Повторный take уже назначенным актором и повтор change-ticket-status на уже достигнутый статус — no-op без нового события и без ошибки; переход на иной недопустимый статус — SUPPORT_TICKET_STATUS_TRANSITION. Дублей интеграционных событий на потребителе снимает дедупликация по MessageId.

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
TicketCreated / TicketStatusChangedдоменноеСоздание / смена Statusвнутренние подписчики (таймлайн, счётчики)
TicketCreatedEventинтеграционное, topic pin.support.ticket-created.v1, ContractVersion = 1Создание тикетаTicketId, Number, TenantId, QueueId, Category, Priority, Status, RequesterUserId, RequesterCompanyId?, RelatedDealId?, Channel, CreatedAt; потребитель: notifications; контракт — ../events/ticket-created.md
TicketStatusChangedEventинтеграционное, topic pin.support.ticket-status-changed.v1, ContractVersion = 1Смена StatusTicketId, Number, TenantId, RequesterUserId, OldStatus, NewStatus, AssigneeUserId?, ChangeReason?, ChangedAt; потребитель: notifications; контракт — ../events/ticket-status-changed.md

Интеграционные события публикуются в одном согласованном изменении с мутацией и переходом статуса.

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK; ux_tickets_number unique; ix_tickets_tenant_id; ix_tickets_status; ix_tickets_queue_id; ix_tickets_assignee partial where assignee_user_id is not null; ix_tickets_requester_user_id; ix_tickets_last_message_at desc; ix_tickets_created_at desc; partial btree (first_response_due_at) where status in ('New','InWork') и (resolution_due_at) where status = 'InWork' — выборки SLA-worker
Поискполнотекстовый поиск по номеру/теме/ФИО/телефону заявителя — Elasticsearch-проекция support-tickets (../search/tickets.md, ADR-0059), а не PostgreSQL-FTS; проекция наполняется доменными событиями тикета + rebuild
Партиционирование / retentionnone (низкий объём, ADR-0056)
Concurrencyоптимистическая блокировка: гонка worker (SLA) ↔ оператор (статус/ответ) — правка не должна затирать чужую
Permissions / PIIsupport.tickets.view (наблюдатель) / support.tickets.manage (оператор) / support.tickets.assign / support.tickets.escalate (супервизор) — platform-scope (ADR-0052). Public-заявитель — только свой тикет (RequesterUserId = actor), Internal-заметки переписки исключены. Оператор — record-level по AssigneeUserId. PII: RequesterName/RequesterPhone/RequesterEmail — маска в логах, наружу — по permission.
Auditотметки создания/изменения/удаления (кто и когда), soft-delete; переписка — append-only (ticket-message.md)

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

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

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