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 |
| Kind | AggregateRoot (дочерняя TicketMessageEntity — переписка внутри агрегата) |
| Source of truth | support |
| Таблица | support.tickets (+ support.ticket_messages) |
| Tenant isolation | TenantId = PIN CompanyId — платформенный инструмент; доступ по platform-scope, не по агентству/застройщику (ADR-0052) |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор тикета. |
TenantId | Guid | Да | index ix_tickets_tenant_id | app | PIN CompanyId (единый tenant платформы). |
Number | long | Да | unique ux_tickets_number; PG sequence | db (sequence) | Человекочитаемый номер (SUP-NNN) для ссылок и поиска. |
Subject | string | Да | varchar(256), не пустое; индексируется в Elasticsearch (support-tickets) | app (заявитель/оператор) | Тема обращения. |
Category | TicketCategory | Да | .HasConversion<string>(), varchar(32) | app | Тематика (анализ тематик, п. 8.4). |
Priority | TicketPriority | Да | .HasConversion<string>(), varchar(16), default Normal | app | Приоритет (влияет на SLA-дедлайны). |
Status | TicketStatus | Да | .HasConversion<string>(), varchar(16), index ix_tickets_status | app | Статус жизненного цикла (см. enum). |
QueueId | Guid | Да | FK fk_tickets_queue_id → support.ticket_queues(id), index ix_tickets_queue_id | app (маршрутизация) | Текущая очередь. |
AssigneeUserId | Guid? | Нет | index partial ix_tickets_assignee where assignee_user_id is not null | app | Назначенный оператор (record-level доступ). null — не взят. |
Channel | TicketChannel | Да | .HasConversion<string>(), varchar(16) | app | Канал поступления (см. enum). Иммутабельно. |
RequesterUserId | Guid | Да | index ix_tickets_requester_user_id; логическая ссылка на identity.users(id) | app | Заявитель; ключ fan-out консюмера identity. |
RequesterCompanyId | Guid? | Нет | index where not null | app | Компания заявителя (контекст/фильтр). |
RequesterRole | string? | Нет | varchar(64) | app | Роль заявителя на момент обращения (снапшот). |
RequesterName | FullName (owned) | Да | см. VO ниже | реплика identity | ФИО заявителя (display-реплика). |
RequesterPhone | string? | Нет | varchar(32); PII (маска в логах) | реплика identity | Телефон заявителя. |
RequesterEmail | string? | Нет | varchar(256); PII (маска в логах) | реплика identity | Email заявителя. |
IdentityRequesterVersion | long? | Нет | — | consumer pin.identity.users.v1 | Version-guard реплики ФИО/контактов (защита от out-of-order обновлений). |
RelatedDealId | Guid? | Нет | index where not null; логическая ссылка на crm | app | Связанная сделка (контекст обращения). |
RelatedPropertyObjectId | Guid? | Нет | логическая ссылка на chessboard | app | Связанный объект недвижимости. |
RelatedCompanyId | Guid? | Нет | — | app | Связанная компания (предмет обращения). |
ArbitrageCaseId | Guid? | Нет | FK fk_tickets_arbitrage_case_id → support.arbitrage_cases(id) where not null | app | Порождённый из тикета арбитражный кейс, если есть. |
MessagesCount | int | Да | default 0, >= 0 | app (domain event переписки) | Счётчик сообщений. |
LastMessageAt | DateTimeOffset? | Нет | index ix_tickets_last_message_at desc | app | Момент последнего сообщения (сортировка списков). |
FirstResponseAt | DateTimeOffset? | Нет | — | app | Первый публичный ответ оператора (SLA, п. 8.1); ставится один раз. |
FirstResponseDueAt | DateTimeOffset | Да | — | app (политика SLA по Priority при создании) | Дедлайн первого ответа; сканируется worker. |
ResolutionDueAt | DateTimeOffset? | Нет | — | app (политика SLA по Priority) | Дедлайн решения. |
SlaBreached | bool | Да | default false | worker | Признак нарушения SLA (выставляет worker). |
ResolvedAt | DateTimeOffset? | Нет | — | app | Момент перехода в Resolved. |
ClosedAt | DateTimeOffset? | Нет | — | app | Момент перехода в Closed. |
ReopenedCount | int | Да | default 0, >= 0 | app | Число переоткрытий (реопен из Closed/Resolved). |
EscalatedToAdminAt | DateTimeOffset? | Нет | — | app (супервизор) | Момент эскалации на администратора. |
EscalatedByUserId | Guid? | Нет | — | app | Кто эскалировал. |
CreatedAt | DateTimeOffset | Да | index ix_tickets_created_at desc | app | Создание обращения. |
CreatedByUserId | Guid? | Нет | — | app | Кто создал (null — WebForm-самозаявка/консюмер). |
UpdatedAt | DateTimeOffset | Да | — | app | Момент последнего изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший. |
DeletedAt | DateTimeOffset? | Нет | — | app | Soft-delete (административно / спам). |
DeletedByUserId | Guid? | Нет | — | 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_*.
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
FirstName | string | Да | varchar(128), не пустое | requester_name_first_name |
LastName | string? | Нет | varchar(128) | requester_name_last_name |
MiddleName | string? | Нет | varchar(128) | requester_name_middle_name |
Модели JSONB (IDocument, versioned)
JSONB-модели не используются: вложения и типизированные тела сообщений — в ticket-message.md.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
TicketQueueEntity | reference | QueueId | загрузка при создании / переводе в очередь | restrict |
TicketMessageEntity | owned-child | ticket_messages.ticket_id | выборка с пагинацией (карточка) | cascade |
ArbitrageCaseEntity | reference (nullable) | ArbitrageCaseId | загрузка при открытии карточки арбитража | restrict |
| Заявитель / сделка / объект недвижимости | внешняя логическая | RequesterUserId / RelatedDealId / RelatedPropertyObjectId | display-данные 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-5 | FirstResponseAt ставится один раз — при первом публичном (не Internal) сообщении оператора | при добавлении публичного сообщения оператора | — |
| INV-TKT-6 | SLA-переходы (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.assign | AssigneeUserId задан (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 или Closed | Status = InWork; из Closed — ReopenedCount++; событие 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-guardIdentityRequesterVersion: применяется только событие с версией строго новее сохранённой (защита от 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 | Смена Status | TicketId, 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 |
| Партиционирование / retention | none (низкий объём, ADR-0056) |
| Concurrency | оптимистическая блокировка: гонка worker (SLA) ↔ оператор (статус/ответ) — правка не должна затирать чужую |
| Permissions / PII | support.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) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (4): Entity / Aggregate: ArbitrageCaseEntity (+ ArbitrageNoteEntity), Entity / Aggregate: TicketQueueEntity, Entity / Aggregate: TicketTemplateEntity, Entity: TicketMessageEntity (child of Ticket)
- API (23): GET /api/public/support/tickets — Мои обращения, GET /api/public/support/tickets/{id} — Моё обращение и переписка, GET /api/support/arbitrage-cases/{id} — Карточка арбитражного дела, GET /api/support/dashboard — Дашборд обращений Службы заботы, GET /api/support/queues — Список очередей, GET /api/support/templates — Список шаблонов ответов, GET /api/support/tickets — Список обращений (фильтры), GET /api/support/tickets/{id} — Карточка обращения (GetTicket), PATCH /api/support/tickets/{id} — Приоритет/категория/очередь, POST /api/public/support/tickets — Подать обращение, POST /api/public/support/tickets/{id}/messages — Ответ заявителя, POST /api/support/arbitrage-cases — Регистрация арбитражного дела (+11)
- Use Cases (4): UC-SUP-001. Приём обращения и назначение оператора, UC-SUP-002. Решение обращения, UC-SUP-003. Арбитраж конфликта через аудит, UC-SUP-005. Эскалация администратору
- События (4): События сервиса support: карта топиков и решения об интеграциях, Event Contract: pin.support.ticket-created (TicketCreatedKafkaEvent), Event Contract: pin.support.ticket-message-posted (TicketMessagePostedKafkaEvent), Event Contract: pin.support.ticket-status-changed (TicketStatusChangedKafkaEvent)
- Консюмеры (1): Consumer: Support Identity User Sync
- Фоновые задачи (1): Background Job: TicketSlaMonitorJob (worker ticket-sla-monitor)
- Поиск (1): TicketSearchDocument
- Дашборды (1): Панель Наблюдаемости: support-overview (эксплуатация)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): Support Service
Связанные документы
- ticket-message.md, ticket-queue.md,
arbitrage-case.md; события — ../events/ticket-created.md,
../events/ticket-status-changed.md; поиск —
../search/tickets.md (Elasticsearch-проекция
support-tickets). - README контекста; tenancy — ADR-0052, масштаб/фон — ADR-0056, поиск — ADR-0059.