Entity / Aggregate: DealTimelineEventEntity (TPH)
Дом полей сущности «Событие таймлайна сделки» контекста crm — вкладка «Ход сделки» (US-INV-09/10/11,
US-AGT-05; глоссарий «Таймлайн сделки»). Все проекции (лента карточки сделки, счётчики «новых событий»,
карточки чата messenger) ссылаются сюда и описывают только дельту.
Назначение
DealTimelineEventEntity — append-only лента фактов сделки с датами и статусом выполнения
(выполнено / в процессе / просрочено — US-INV-09). Записывается в одном согласованном изменении с
породившей мутацией агрегата: доменные события сделки/брони/документов материализуются в строки таймлайна.
Таблица использует single-table наследование (TPH) по дискриминатору Type; типизированные данные —
versioned jsonb Payload (модели ниже).
Таймлайн сделки видят все участники сделки (инвестор, агент, застройщик, Менеджер ПИН); лента лида
(lead.md LeadEventEntity) видна только агентской стороне.
Классификация
| Поле | Значение |
|---|---|
| Контекст | crm |
| Kind | Entity (append-only, в составе агрегата сделки; отдельная таблица) |
| Source of truth | crm |
| Таблица | crm.deal_timeline_events (TPH) |
| Tenant isolation | TenantId = DeveloperCompanyId (наследуется от сделки) |
Поля (базовая часть TPH)
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор события. |
TenantId | Guid | Да | index | из сделки | Застройщик сделки. |
DealId | Guid | Да | FK fk_deal_timeline_deal_id → crm.deals(id), index ix_deal_timeline_deal_occurred (DealId,OccurredAt) | app | Сделка (deal.md). |
Type | DealTimelineEventType | Да | discriminator TPH, enum как text, varchar(64), index (deal_id,type) | app | Тип события (см. enum). |
Status | TimelineItemStatus | Да | enum как text, varchar(32) | app / worker | Иконка статуса US-INV-09: Done / InProgress / Overdue. |
Title | string | Да | varchar(256) | app (генерация по типу) | Отображаемый заголовок («Создана заявка на бронирование»). |
OccurredAt | DateTimeOffset | Да | часть индекса ленты | app | Когда факт произошёл (сортировка «от старых к новым», US-INV-09). |
DueAt | DateTimeOffset? | Нет | partial index where status = 'InProgress' (worker) | app | Дедлайн для InProgress-шагов; просрочка → Overdue (polling-worker, ADR-0056). |
CompletedAt | DateTimeOffset? | Нет | — | app | Когда шаг закрыт (→ Done). |
ActorUserId | Guid? | Нет | — | app | Кто совершил действие; null — система/worker. |
ActorRole | ParticipantRole? | Нет | enum как text, varchar(32); enum — deal.md | app | Роль актора на момент события. |
Payload | typed jsonb per-type | Да | jsonb; модели ниже; ContractVersion | app | Типизированные данные события. |
CreatedAt | DateTimeOffset | Да | — | app | Запись строки. |
UpdatedAt | DateTimeOffset | Да | — | app / worker | Дата обновления Status. |
Enum TimelineItemStatus (US-INV-09.3)
| Значение | Описание |
|---|---|
Done | Выполнено (факт свершился / целевое действие закрыто). |
InProgress | В процессе: событие требует действия (например «загрузите документы») и ещё не закрыто. |
Overdue | Просрочено: DueAt < now, действие не выполнено (переводит worker timeline-overdue). |
Enum DealTimelineEventType и typed payload (jsonb)
Все payload-модели — ContractVersion = 1; правило совместимости — additive-only, breaking-изменение
требует нового ContractVersion. Поля с ? — nullable, остальные обязательны.
| Тип | Когда пишется (источник) | Payload-модель |
|---|---|---|
DealCreated | создание сделки | TimelineDealCreatedPayload { LeadId: Guid, FixationRequestId: Guid?, PropertyObjectId: Guid, PaymentMethod: string } |
StageChanged | Deal.MoveToStep | TimelineStageChangedPayload { FromStepId: Guid, FromStepName: string, ToStepId: Guid, ToStepName: string, ToStepGroup: string } |
StatusChanged | смена Deal.Status | TimelineStatusChangedPayload { FromStatus: string, ToStatus: string, Reason: string? } |
PropertyObjectChanged | Deal.ChangePropertyObject (ТЗ.md:457) | TimelinePropertyObjectChangedPayload { OldPropertyObjectId: Guid, NewPropertyObjectId: Guid, OldPriceAmount: decimal, NewPriceAmount: decimal, Currency: string } |
MemberAdded | Deal.AddMember | TimelineMemberPayload { UserId: Guid, Role: string } |
MemberRemoved | Deal.RemoveMember | TimelineMemberPayload (та же модель) |
BookingRequested | создание брони (booking.md); клик раскрывает условия (US-INV-10) | TimelineBookingRequestedPayload { BookingId: Guid, PropertyObjectId: Guid, ProgramName: string, IsPaid: bool, PriceAmount: decimal?, Currency: string, ReserveDurationMinutes: int, BookingDurationHours: int, RefundPolicy: string? } |
BookingReserveExpired | worker истечения резерва | TimelineBookingRefPayload { BookingId: Guid } |
BookingPaid | ConfirmPayment (US-INV-06) | TimelineBookingPaidPayload { BookingId: Guid, Amount: decimal, Currency: string, PaidAt: DateTimeOffset } |
BookingConfirmed | → Booked | TimelineBookingConfirmedPayload { BookingId: Guid, BookingExpiresAt: DateTimeOffset } |
BookingExpired | worker истечения брони | TimelineBookingRefPayload |
BookingExtended | Booking.Extend | TimelineBookingExtendedPayload { BookingId: Guid, NewBookingExpiresAt: DateTimeOffset } |
DocumentRequested | запрос документа (InProgress + DueAt) | TimelineDocumentPayload { DocumentId: Guid, DocumentType: string, Title: string, RequestComment: string? } |
DocumentUploaded | загрузка файла (закрывает соответствующий DocumentRequested → Done) | TimelineDocumentPayload |
DocumentAccepted | Review(Accept) | TimelineDocumentPayload |
DocumentRejected | Review(Reject) | TimelineDocumentRejectedPayload { DocumentId: Guid, DocumentType: string, RejectReason: string } |
EscrowProvided | инвестор указал эскроу (US-INV-19) | TimelineEscrowPayload { BankId: Guid?, AccountNumberMasked: string } — только маска ****NNNN |
ContractsPrepared | US-DEV-04 / US-INV-20 | TimelineContractsPreparedPayload { ContractType: string } |
RosreestrRegistered | ТЗ.md:407 | TimelineEmptyPayload { } |
DealCompleted | Deal.Complete | TimelineEmptyPayload { } |
DealCancelled | Deal.Cancel | TimelineCancelledPayload { Reason: string } |
SelectionSent | агент отправил подборку в рамках сделки (ТЗ.md:447) | TimelineSelectionPayload { SelectionId: Guid, SelectionTitle: string } |
TermsAcknowledged | инвестор отметил «Ознакомлен» (US-INV-18) | TimelineEmptyPayload { } |
ManualNote | ручная запись Менеджера ПИН / Службы заботы (US-MGR) | TimelineManualNotePayload { Text: string (varchar 2000) } |
Value objects
Value objects не используются (типизация — через per-type jsonb payload).
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
DealEntity | reference (родитель) | DealId | выборка по DealId с сортировкой OccurredAt asc (лента) | cascade |
DealDocumentEntity / BookingEntity | логическая (id в payload) | Payload.DocumentId / Payload.BookingId | display-ссылки UI | — |
Инварианты и переходы состояний
| Инвариант | Условие | Когда проверяется | Ошибка |
|---|---|---|---|
| INV-TLE-1 | Append-only: контент события (Type,OccurredAt,Payload,ActorUserId) иммутабелен; меняется только Status/CompletedAt/UpdatedAt | по построению (контент не редактируется) | — |
| INV-TLE-2 | Запись — в одном согласованном изменении с породившей мутацией | при записи строки таймлайна | — |
| INV-TLE-3 | InProgress требует DueAt либо явного закрытия парным событием (например DocumentRequested закрывается DocumentUploaded) | при добавлении строки таймлайна | CRM_TIMELINE_DUE_REQUIRED |
| INV-TLE-4 | Overdue ставит только worker timeline-overdue при Status = InProgress and DueAt < now; идемпотентно (ADR-0056) | фоновая задача timeline-overdue | — |
| INV-TLE-5 | ManualNote создают только PinManager/Служба заботы (арбитраж) | при создании заметки | CRM_TIMELINE_MANUAL_FORBIDDEN |
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
TimelineItemAdded | доменное | добавление строки | DealId, EventId, Type — триггер push/email «новое событие в таймлайне» (US-INV-11) через notifications |
Таймлайн не публикует собственных интеграционных событий: их порождают сами операции-источники
(deal.md, booking.md, deal-document.md) — таймлайн
лишь их локальная материализация. Уведомление US-INV-11 доставляет notifications как консюмер тех же
событий.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK; ix_deal_timeline_deal_occurred (deal_id,occurred_at); (deal_id,type); partial (due_at) where status = 'InProgress' (worker); (tenant_id) |
| Партиционирование / retention | none (десятки строк на сделку; ADR-0056). Пересмотр — при реальном росте |
| Concurrency | нет (append-only + однопоточный worker overdue) |
| Permissions / PII | видимость = видимость сделки (record-level deal.md); crm.deals.view. PII в payload запрещён: эскроу — только маска, документы — только id/тип/заголовок. |
| Audit | сама лента — аудит бизнес-процесса; технический audit — на агрегатах-источниках |
Материализация, порядок и восстановление
Материализация. Таймлайн — локальная проекция: строка пишется в одном согласованном изменении с
породившей мутацией агрегата (создание сделки/брони/документа, смена этапа/статуса, оплата и т.п.,
INV-TLE-2). Собственных Kafka-событий таймлайн не публикует; интеграционные события порождают сами
операции-источники, а уведомление US-INV-11 доставляет консюмер notifications тех же событий. Таким
образом, для пользователя строка таймлайна появляется раньше или одновременно с внешними эффектами
(карточка в чате, push): данные crm фиксируются атомарно, downstream — eventual.
Порядок ленты. Сортировка — по OccurredAt источника (ix_deal_timeline_deal_occurred), не по времени
записи; поэтому событие, доставленное консюмером с задержкой, встаёт на свою историческую позицию, а не в
конец.
Парные события и просрочка. Целевое действие пишется как InProgress с DueAt либо закрывается парным
событием (DocumentRequested → DocumentUploaded), INV-TLE-3. Worker timeline-overdue при
Status = InProgress and DueAt < now переводит строку в Overdue (только он, INV-TLE-4); проход
идемпотентен (ADR-0056) — повторная обработка не меняет уже Overdue/Done строку и не плодит уведомлений.
Отказы, идемпотентность и конкуренция
- Append-only иммутабельность. Контент строки (
Type/OccurredAt/Payload/ActorUserId) не редактируется (INV-TLE-1); меняются толькоStatus/CompletedAt/UpdatedAt. Дублей нет, т.к. запись идёт атомарно с единственной мутацией-источником. - Конкуренция. Практически отсутствует: лента append-only,
Overdueставит однопоточный worker. ЗакрытиеInProgressпарным событием и параллельная простановкаOverdueразрешаются по статусу — как только строкаDone, worker её не трогает. - PII-guard. В
Payloadперсональные данные запрещены: эскроу — только маска****NNNN, документы — только id/тип/заголовок; нарушение недопустимо по построению payload-моделей. - Ручные заметки.
ManualNoteсоздают толькоPinManager/Служба заботы (INV-TLE-5) → иначеCRM_TIMELINE_MANUAL_FORBIDDEN.
Acceptance-критерии
- Happy (факт-событие). Дано: сделка создана. Когда фиксируется мутация. Тогда в ленте строка
DealCreatedсоStatus = Done, видимая всем участникам сделки, в том же согласованном изменении. - Целевое действие и просрочка. Дано:
DocumentRequestedсDueAtв прошлом, не закрыт. Когда работает workertimeline-overdue. Тогда строкаOverdue; повторный проход ничего не меняет. - Закрытие парным событием. Дано:
DocumentRequestedInProgress. Когда приходитDocumentUploaded. Тогда исходная строка →Done(CompletedAt). - Отсутствие
DueAtу целевого действия. Дано:InProgress-строка безDueAtи без парного закрытия. Тогда отказCRM_TIMELINE_DUE_REQUIRED. - Ручная заметка чужим актором. Дано: не
PinManager/Служба заботы. Когда создаётManualNote. Тогда отказCRM_TIMELINE_MANUAL_FORBIDDEN. - Порядок при поздней доставке. Дано: событие с ранним
OccurredAtдоставлено позже. Когда оно записано. Тогда в ленте оно стоит поOccurredAt, а не в конце.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: DealDocumentEntity, Entity / Aggregate: DealEntity
- API (29): DELETE /api/agent/crm/deals/{dealId}/members/{memberId} — Удалить участника сделки, GET /api/agent/crm/deals/{dealId} — Карточка сделки (агент), GET /api/investor/crm/deals/{dealId}/timeline — Таймлайн сделки (инвестор), GET /api/pin-manager/crm/deals/{dealId} — Карточка любой сделки (Менеджер ПИН), POST /api/agent/crm/bookings/{bookingId}/cancel — Отменить бронь, POST /api/agent/crm/deals — Создать сделку из лида по объекту, POST /api/agent/crm/deals/{dealId}/engage-developer — Вовлечь застройщика (Draft → Application), POST /api/agent/crm/deals/{dealId}/members — Добавить участника сделки, POST /api/agent/crm/deals/{dealId}/selection — Отправить подборку в сделку, POST /api/developer/crm/bookings/{bookingId}/confirm-documents — Подтвердить документы брони, POST /api/developer/crm/bookings/{bookingId}/confirm-payment — Подтвердить оплату брони, POST /api/developer/crm/bookings/{bookingId}/extend — Продлить бронь (+17)
- Use Cases (13): UC-BKG-002. Бронирование Резерв → Бронь, UC-BKG-003. Истечение резерва/брони и продление, UC-BKG-004. Оплата платного бронирования (чек + подтверждение), UC-CRM-003. Движение сделки по канбан-доске, UC-CRM-004. Документы сделки: запрос → загрузка → проверка → статусы, UC-CRM-005. Оформление сделки: договоры → эскроу → Росреестр → завершение, UC-CRM-006. Вмешательство Менеджера ПИН, UC-CRM-007. Добавление участника в сделку, UC-CRM-008. Снятие участника со сделки, UC-CRM-009. Передача ведения сделки (смена исполнителя), UC-CRM-010. Назначение/снятие ответственного (куратора) процесса — ответственный ≠ исполнитель, UC-CRM-015. Явное назначение исполнителя для разрешения спора о комиссии (+1)
- События (1): Event Contract: pin.crm.document-status-changed (DocumentStatusChangedEvent)
- Фоновые задачи (1): Background Job: TimelineOverdueJob (worker timeline-overdue)
- index.md (1): CRM Service
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- deal.md — источники событий и статусная модель; booking.md, deal-document.md — порождающие операции; lead.md — аналогичная лента лида.
- Worker
timeline-overdue—../background/timeline-overdue-job.md(будущий путь по конвенции).