Entity / Aggregate: LeadEntity
Дом полей агрегата «Лид» контекста crm (глоссарий «Лид»). Все проекции (карточка лида агента, списки
лидов, payload событий, override хотелок в фиксации) ссылаются сюда и описывают только дельту.
Назначение
LeadEntity — активная связь «инвестор ↔ агент», о которой не знает застройщик: карточка пути клиента
для агента. Лид принадлежит компании-агентству (TenantId), а агенты и инвесторы — его участники
(LeadParticipantEntity, единый ParticipantRole, ADR-0071): текущий исполнитель — активный Executor,
основной клиент — активный InvestorPrimary; допустимы со-агенты, ассистент, юрист и созаёмщики. Хранит
«шапку хотелок» инвестора (бюджет, город, срочность, цели) и ленту событий клиента
(LeadEventEntity): просмотры объектов, подборки, заявки на консультацию/бронирование, применённые фильтры,
записи на показы/консультации — наполняется консюмерами событий user-activity, referral, selections,
calendar (записи через слоты) и заявками из catalog/front (UC-1/2/3).
Хранит и намерения бронирования (LeadBookingIntentEntity) — «хотелки на конкретный объект»
инвестора: какие объекты он готов купить/забронировать. Из подтверждённого намерения (и одобренной
фиксации застройщика) порождается сделка. Из лида порождаются заявки на фиксацию
(fixation-request.md) и сделки (deal.md): у одного лида много
одновременных фиксаций и сделок к одному или разным застройщикам. Лид «живёт» ограниченный срок (~месяц)
и продлевается агентом или активностью клиента.
Классификация
| Поле | Значение |
|---|---|
| Контекст | crm |
| Kind | AggregateRoot (дочерние LeadParticipantEntity — состав участников, LeadBookingIntentEntity — намерения бронирования, LeadEventEntity — лента, TPH) |
| Source of truth | crm |
| Таблица | crm.leads (+ crm.lead_participants, + crm.lead_booking_intents, + crm.lead_events, TPH) |
| Tenant isolation | TenantId = AgencyCompanyId (ADR-0052: «агентство для лида»); совпадает с Origin-агентством (ADR-0071) |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор лида. |
TenantId | Guid | Да | index ix_leads_tenant_id | identity (actor) / consumer привязки | Компания агентства (или ПИН) = AgencyCompanyId = Origin-агентство (кто привёл клиента). Record-level snapshot, иммутабелен (ADR-0061 п.4, ADR-0071, INV-LEAD-6). |
ClientId | Guid | Да | FK fk_leads_client_id → crm.clients(id), index ix_leads_client_id | app / consumer | Карточка клиента (client.md); создаётся вместе с первым лидом, если отсутствовала. |
OriginAgentUserId | Guid | Да | index ix_leads_origin_agent_user_id | app / consumer привязки | Атрибуция: агент, создавший лид («кто привёл клиента»). Для UC-2 — случайный сотрудник компании ПИН (ТЗ.md:529). Record-level snapshot, иммутабелен (ADR-0061 п.4, ADR-0071, INV-LEAD-6): не переписывается при смене агентом агентства/передаче ведения — основа исторической воронки агентства, оснований комиссии и антифрода. |
ExecutorAgentUserId | Guid | Да | index ix_leads_executor_agent_user_id; часть ux_leads_active_pair | derived (участник Executor) | Текущий исполнитель лида — денормализованная проекция активного участника с ролью Executor (получатель комиссии). Мутабельна: меняется при передаче ведения, поддерживается атомарно со сменой состава участников. Единственный источник истины исполнителя — участник Executor (см. LeadParticipantEntity); отдельного скаляра-владельца нет. |
ResponsibleAgentUserId | Guid? | Нет | index where not null | derived (участник Responsible) | Ответственный/куратор ведения — денормализованная проекция активного участника с ролью Responsible; опциональна, может ≠ ExecutorAgentUserId. Мутабельна. |
PrimaryInvestorUserId | Guid | Да | index; часть ux_leads_active_pair | derived (участник InvestorPrimary) | Основной инвестор пары — денормализованная проекция активного участника с ролью InvestorPrimary. Инвесторы лида ведутся составом участников (InvestorPrimary ровно один + InvestorSecondary — сколько угодно). Лид создаётся только для зарегистрированного (в т.ч. lazy) пользователя. |
Status | LeadStatus | Да | enum как text, varchar(32), index ix_leads_status; unique partial ux_leads_active_pair on (ExecutorAgentUserId,PrimaryInvestorUserId) where status = 'Active' | app | См. enum и INV-CRM-001. |
Wishes | LeadWishes (owned) | Да (все поля VO nullable) | см. VO ниже | app (форма квалификации агента) / consumer заявки | «Шапка хотелок» инвестора; сохраняется в лиде, фиксация делает только override-снимок. |
Source | LeadSource | Да | enum как text, varchar(32) | consumer / app | Как появился лид (см. enum). Иммутабельно. |
ReferralLinkId | Guid? | Нет | index where not null; логическая ссылка на referral | consumer pin.referral.* | Реферальная ссылка, породившая привязку (если была). |
ExpiresAt | DateTimeOffset | Да | index ix_leads_expires_at | app: CreatedAt + 1 месяц; продление +1 месяц | Срок жизни лида («~месяц, продлевается»). Проверяется polling-worker-ом (ADR-0056), не TTL-магией. |
ExtensionsCount | int | Да | >= 0, default 0 | app | Сколько раз лид продлевался (ExtendLead). |
LastExtendedAt | DateTimeOffset? | Нет | — | app | Момент последнего продления (агентом или авто-продления по активности клиента). |
LastEventAt | DateTimeOffset? | Нет | index ix_leads_last_event_at desc | app (при добавлении LeadEventEntity) | Последнее событие в ленте — сортировка списка лидов. |
ExpiredAt | DateTimeOffset? | Нет | — | background job | Фактический момент перевода в Expired. |
ArchivedAt | DateTimeOffset? | Нет | — | app | Момент ручного архивирования. |
ArchiveReason | string? | Нет | varchar(1000) | app | Причина архивирования (агент указал). |
CreatedAt | DateTimeOffset | Да | index ix_leads_created_at | app | Создание лида. |
CreatedByUserId | Guid? | Нет | — | app | null — создан консюмером (заявка/реферал); иначе агент. |
UpdatedAt | DateTimeOffset | Да | — | app | Дата последнего изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший. |
DeletedAt | DateTimeOffset? | Нет | — | app | Soft-delete (административно). |
DeletedByUserId | Guid? | Нет | — | app | Кто скрыл. |
Enum LeadStatus
| Значение | Описание |
|---|---|
Active | Рабочая связь: события пишутся, можно создавать фиксации и сделки. |
Expired | Срок жизни истёк (ExpiresAt < now, переведён worker-ом). Историческая запись; можно реактивировать продлением. |
Archived | Агент вручную закрыл лид (клиент отказался, дубль и т.п.). |
Enum LeadSource
| Значение | Описание |
|---|---|
ConsultationRequest | Заявка на консультацию/презентацию (UC-1/UC-2; «Заявка на презентацию»). |
BookingRequest | Заявка на бронирование объекта (UC-3, кнопка «Забронировать»). |
Referral | Реферальная привязка при регистрации (событие pin.referral.lead-attachments.v1). |
Appointment | Запись на показ/консультацию через слот календаря (события pin.calendar.appointment-*.v1, консюмер calendar-appointments.md). |
Manual | Агент создал лид вручную из карточки клиента. |
Value objects
LeadWishes (owned) — «шапка хотелок»
Типизированные хотелки инвестора (бюджет, желаемый город, срочность, цели; форма квалификации — бюджет,
срочность, способ покупки). Тот же контракт переиспользуется snapshot-ом FixationWishesOverride в
fixation-request.md.
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
Budget | NullableRange<decimal> (owned VO) | Нет | numeric(18,2) на границу, >= 0, Min <= Max | wishes_budget_min / wishes_budget_max |
BudgetCurrencyId | short? | Нет | типизированная ссылка на справочник валют handbook (без FK); не задан → трактуется как RUB (RUB-only, ADR-0048); валидация на write-path — internal resolve (неизвестная/архивная валюта → отказ валидации, update-lead-wishes.md); display-код — из снапшотов/lookup справочника (snapshots.md) | wishes_budget_currency_id |
CityId | long? | Нет | типизированная ссылка на справочник городов handbook (без FK); валидация на write-path — internal resolve, неизвестный/архивный город → CRM_CITY_UNKNOWN (update-lead-wishes.md) | wishes_city_id |
CityName | string? | Нет | varchar(256); денормализованное display-имя города, снятое из resolve при записи (при переименовании в справочнике не «догоняется» — живёт до следующей правки хотелок); заполнено только вместе с CityId | wishes_city_name |
Urgency | LeadUrgency? | Нет | enum как text, varchar(32) | wishes_urgency |
PurchaseGoal | PurchaseGoal? | Нет | enum как text, varchar(32) | wishes_purchase_goal |
PreferredPaymentMethod | PaymentMethod? | Нет | enum как text, varchar(32); enum — purchase-program.md | wishes_preferred_payment_method |
Comment | string? | Нет | varchar(2000) | wishes_comment |
Enum LeadUrgency
| Значение | Описание |
|---|---|
Immediate | Готов к сделке сейчас (например, «лид приехал в город»). |
WithinMonth | Покупка в горизонте месяца. |
WithinQuarter | Покупка в горизонте квартала. |
Undecided | Срок не определён / изучает рынок. |
Enum PurchaseGoal
| Значение | Описание |
|---|---|
Investment | Инвестиция (доходность/перепродажа). |
RentalIncome | Арендный доход. |
OwnLiving | Для собственного проживания. |
Other | Иное (уточняется в Comment). |
Дочерняя сущность LeadParticipantEntity — состав участников
Таблица crm.lead_participants. Состав агентских и инвесторских участников лида (единый ParticipantRole,
ADR-0071); активный участник — RemovedAt is null. Из активного состава денормализуются производные лида
ExecutorAgentUserId / ResponsibleAgentUserId / PrimaryInvestorUserId (поддерживаются атомарно со
сменой состава). Видимость лида и синк участников чата лида (ChatKind.Lead, messenger) строятся на этом
составе, а не на скаляре.
| Поле | Тип | Обязательность | Ограничения | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор участия. |
LeadId | Guid | Да | FK fk_lead_participants_lead_id → crm.leads(id) cascade; unique partial ux_lead_participants_active_user (LeadId,UserId) where removed_at is null | app | Лид. |
TenantId | Guid | Да | index | из лида | Дублируется для tenant-фильтра без join. |
UserId | Guid | Да | index ix_lead_participants_user_id; логическая ссылка на identity.users(id) | app | Пользователь-участник. |
Role | ParticipantRole | Да | enum как text, varchar(32) | app | Роль участника (см. enum). Меняется операцией смены роли участника. |
IsPrimary | bool | Да | default false | app | Признак «основной» в своей роли (основной инвестор InvestorPrimary / первичный исполнитель); поддерживает денормализацию производных лида. |
AddedByUserId | Guid? | Нет | — | app | Кто добавил (null — система: авто-состав при создании лида). |
CreatedAt | DateTimeOffset | Да | — | app | Момент добавления. |
RemovedAt | DateTimeOffset? | Нет | — | app | Soft-удаление участия (история для чата/аудита; активные участники — RemovedAt is null). |
RemovedByUserId | Guid? | Нет | — | app | Кто удалил. |
Enum ParticipantRole
Единое перечисление ролей участников CRM-процессов (лид и сделка; на сделке доступны дополнительные роли сторон, ADR-0071). Additive: неизвестное потребителю значение роли трактуется как строка + warning (не падение).
| Значение | Применимо к лиду | Описание |
|---|---|---|
Executor | Да | Текущий исполнитель — ровно один активный; единственный источник истины исполнителя и получателя комиссии. |
Responsible | Да | Ответственный/куратор ведения; опционален, может ≠ Executor. |
CoAgent | Да | Со-агент (нефинансовый в v1, ADR-0069). |
Assistant | Да | Ассистент (нефинансовый). |
Lawyer | Да | Юрист сопровождения (нефинансовый). |
InvestorPrimary | Да | Основной инвестор — ровно один активный. |
InvestorSecondary | Да | Созаёмщики/соинвесторы — сколько угодно. |
SellManager | Нет (только сделка) | Менеджер продаж застройщика. |
PinManager | Нет (только сделка) | Менеджер ПИН. |
:::note Соответствие старых значений (миграция)
Investor → InvestorPrimary, Agent → Executor; SellManager / PinManager — без изменений.
:::
Дочерняя сущность LeadEventEntity (TPH) — события лида
Лента «что делает и чего хочет клиент» (UC-1..3: «добавлена подборка», «просмотрел подборку», «запросил
консультацию», «запросил бронирование», «записался на показ»). Таблица crm.lead_events, single-table
наследование (TPH) по дискриминатору Type. Наполняется консюмерами
pin.user-activity.property-object-view-ended.v1, pin.user-activity.action-tracked.v1,
pin.referral.lead-attachments.v1, pin.selections.selection-shared.v1,
pin.calendar.appointment-*.v1 (calendar-appointments.md) и
командами заявок (см. README).
| Поле | Тип | Обязательность | Ограничения | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор события. |
LeadId | Guid | Да | FK fk_lead_events_lead_id → crm.leads(id) cascade, index ix_lead_events_lead_id_occurred_at (LeadId,OccurredAt desc) | app | Родительский лид. |
TenantId | Guid | Да | index | из лида | Дублируется для tenant-фильтра без join. |
Type | LeadEventType | Да | discriminator, enum как text, varchar(64) | app | TPH-тип (см. enum). |
OccurredAt | DateTimeOffset | Да | — | consumer (время источника) / app | Когда событие произошло у источника. |
SourceMessageId | Guid? | Нет | unique ux_lead_events_source_message_id where not null | consumer | MessageId Kafka-конверта — идемпотентность консюмеров. |
Payload | typed jsonb per-type | Да | jsonb, модели ниже, ContractVersion | consumer / app | Типизированные данные события. |
CreatedAt | DateTimeOffset | Да | — | app | Запись в ленту. |
Enum LeadEventType и typed payload (jsonb, ContractVersion = 1)
| Тип | Когда | Payload-модель (все поля обязательны, если не помечено ?) |
|---|---|---|
PropertyObjectViewed | consumer pin.user-activity.property-object-view-ended.v1 | LeadPropertyObjectViewedPayload { PropertyObjectId: Guid, ProjectId: Guid?, DurationSeconds: int (>=0), SessionId: Guid } |
FilterApplied | consumer pin.user-activity.action-tracked.v1 (тип «фильтр») | LeadFilterAppliedPayload { CityId: long?, CityName: string?, DeveloperCompanyId: Guid?, ProjectId: Guid?, Price: NullableRange<decimal>, RoomsCount: int?, RawQuery: string (varchar 2000) } — CityId — id справочника городов pin.handbook, CityName — display-имя из ref-модели активности как есть (мягкая семантика, без резолва; user-activity-actions.md) |
FavoriteAdded | consumer pin.user-activity.action-tracked.v1 (тип «избранное») | LeadFavoriteAddedPayload { PropertyObjectId: Guid } |
SelectionAttached | подборка добавлена лиду (UC-1: «в лида была добавлена подборка») | LeadSelectionPayload { SelectionId: Guid, SelectionTitle: string (varchar 256), PropertyObjectsCount: int } |
SelectionViewed | клиент открыл подборку (UC-1: «лид просмотрел подборку») | LeadSelectionPayload (та же модель) |
ConsultationRequested | заявка на консультацию/презентацию (UC-1/UC-2) | LeadConsultationRequestedPayload { SelectionId: Guid?, PropertyObjectId: Guid?, PreferredAt: DateTimeOffset? } — подборка/объект/дата-время опциональны |
BookingRequested | заявка на бронирование (UC-3; объект обязателен) | LeadBookingRequestedPayload { PropertyObjectId: Guid, SelectionId: Guid?, DeveloperCompanyId: Guid } |
FixationCreated | создана заявка на фиксацию из лида | LeadFixationRefPayload { FixationRequestId: Guid, DeveloperCompanyId: Guid } |
BookingIntentAdded | добавлено намерение бронирования (инвестор — InvestorAdded; агент предложил объект — AgentProposed) | LeadBookingIntentRefPayload { IntentId: Guid, PropertyObjectId: Guid, ProjectId: Guid, DeveloperCompanyId: Guid, Source: LeadBookingIntentSource, ConfirmedByInvestor: bool } |
BookingIntentConfirmed | инвестор подтвердил намерение агента (Flow B) | LeadBookingIntentRefPayload (та же модель; ConfirmedByInvestor = true) |
BookingIntentRemoved | намерение отменено/снято (инвестором или агентом) | LeadBookingIntentRefPayload (та же модель) |
DealCreated | создана сделка из лида | LeadDealRefPayload { DealId: Guid, PropertyObjectId: Guid, DeveloperCompanyId: Guid } |
LeadExtended | продление срока жизни | LeadExtendedPayload { NewExpiresAt: DateTimeOffset, ExtendedByUserId: Guid? } |
ShowingAppointmentCreated | consumer pin.calendar.appointment-created.v1 (Kind = Showing) — инвестор записался на показ | LeadAppointmentPayload (общая модель шести appointment-типов, см. ниже) |
ConsultationAppointmentCreated | consumer pin.calendar.appointment-created.v1 (Kind = Consultation; fail-safe для неизвестного Kind) — инвестор записался на консультацию | LeadAppointmentPayload |
AppointmentConfirmed | consumer pin.calendar.appointment-confirmed.v1 — встреча подтверждена (агентом или auto-confirm) | LeadAppointmentPayload (AutoConfirmed заполнен) |
AppointmentCancelled | consumer pin.calendar.appointment-cancelled.v1 — запись отклонена/отменена/просрочена до встречи | LeadAppointmentPayload (Reason заполнен; RescheduledToAppointmentId — перенос, не разрыв) |
AppointmentCompleted | consumer pin.calendar.appointment-completed.v1 (Outcome = Completed; fail-safe для неизвестного Outcome) — встреча состоялась | LeadAppointmentPayload |
AppointmentNoShow | consumer pin.calendar.appointment-completed.v1 (Outcome = NoShow) — клиент не пришёл (сигнал повторной связи) | LeadAppointmentPayload |
LeadAppointmentPayload — общая payload-модель appointment-типов
Заполняется консюмером calendar-appointments.md из контрактов
pin.calendar.appointment-*.v1; обязательные поля есть во всех четырёх контрактах, опциональные — только
в указанных типах.
| Поле | Тип | Обязательность | Заполняется у | Описание |
|---|---|---|---|---|
AppointmentId | Guid | Да | всех типов | Запись в calendar — бизнес-ключ идемпотентности ленты (не более одной записи на пару AppointmentId + тип). |
Kind | string | Да | всех типов | Вид встречи (Showing/Consultation, enum EventKind контекста calendar); неизвестное значение сохраняется строкой как есть. |
StartAt | DateTimeOffset | Да | всех типов | Начало встречи (UTC, иммутабельный снапшот слота). |
ProjectId | Guid? | Нет | *AppointmentCreated | Проект застройщика (chessboard, логическая ссылка) — контекст показа. |
PropertyObjectId | Guid? | Нет | *AppointmentCreated | Объект недвижимости показа (chessboard, логическая ссылка). |
Comment | string? | Нет | *AppointmentCreated | Комментарий инвестора при записи (≤ 1024); класс Internal — маскирование в логах, без индексации в поиске. |
AutoConfirmed | bool? | Нет | AppointmentConfirmed | true — подтверждение при бронировании (auto-confirm слота), false — агент подтвердил вручную. |
Reason | string? | Нет | AppointmentCancelled | Причина (enum AppointmentCancelReason контекста calendar: DeclinedByAgent/CancelledByInvestor/CancelledByAgent/Expired); неизвестное значение — строкой как есть (нейтральное «запись отменена»). |
RescheduledFromAppointmentId | Guid? | Нет | *AppointmentCreated | Прежняя запись атомарной пары переноса — запись ленты трактуется как продолжение, а не новая заявка. |
RescheduledToAppointmentId | Guid? | Нет | AppointmentCancelled | Новая запись пары переноса — отмена выполнена переносом. |
:::info Совместимость payload
Additive-only в рамках ContractVersion = 1; breaking — новый ContractVersion + чтение обоих.
:::
Дочерняя сущность LeadBookingIntentEntity — намерения бронирования
Таблица crm.lead_booking_intents. Намерение инвестора купить/забронировать конкретный объект в
рамках лида («хотелки на объект»). Живёт на пару (LeadId, PropertyObjectId); активное намерение —
Status = Active (RemovedAt is null), их у лида сколько угодно (по разным объектам). Кто инициировал —
сам инвестор (InvestorAdded, подтверждено сразу) или агент, предложивший объект / создавший сделку по
незапрошенному объекту (AgentProposed, ждёт подтверждения инвестором — Flow B). Из подтверждённого
намерения при одобренной фиксации застройщика объекта порождается/раскрывается сделка (см.
deal.md: гейт Draft → Application); связь активного намерения и сделки — 1:1 (DealId).
| Поле | Тип | Обязательность | Ограничения | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор намерения. |
LeadId | Guid | Да | FK fk_lead_booking_intents_lead_id → crm.leads(id) cascade; unique partial ux_lead_booking_intents_active_object (LeadId,PropertyObjectId) where status = 'Active' | app | Родительский лид. |
TenantId | Guid | Да | index ix_lead_booking_intents_tenant_id | из лида | Компания-агентство (= AgencyCompanyId); дублируется для tenant-фильтра без join. |
PropertyObjectId | Guid | Да | index ix_lead_booking_intents_property_object_id; логическая ссылка на chessboard | app / consumer | Объект недвижимости намерения. |
ProjectId | Guid | Да | логическая ссылка на chessboard | app / consumer | Проект застройщика объекта (контекст). Snapshot из объекта. |
DeveloperCompanyId | Guid | Да | index ix_lead_booking_intents_developer_company_id | app / consumer | Застройщик — владелец объекта; пара (инвестор × застройщик) определяет требуемую фиксацию для гейта сделки. Snapshot из объекта. |
Source | LeadBookingIntentSource | Да | enum как text, varchar(32) | app | Кто инициировал намерение (см. enum). Иммутабельно. |
ConfirmedByInvestor | bool | Да | default false | app | Подтверждено инвестором. InvestorAdded → true сразу; AgentProposed → false до подтверждения инвестором. |
ConfirmedAt | DateTimeOffset? | Нет | — | app | Момент подтверждения инвестором (для InvestorAdded = CreatedAt). |
Status | LeadBookingIntentStatus | Да | enum как text, varchar(32), index ix_lead_booking_intents_status | app | См. enum и переходы. |
DealId | Guid? | Нет | index where not null; логическая ссылка на crm.deals(id) | app | Порождённая из намерения сделка (1:1 активного намерения). null — сделки пока нет. |
AddedByUserId | Guid? | Нет | — | app | Кто добавил (инвестор или агент); null — консюмер заявки. |
CreatedAt | DateTimeOffset | Да | — | app | Момент создания намерения. |
RemovedAt | DateTimeOffset? | Нет | — | app | Soft-снятие (Status = Cancelled); активные — RemovedAt is null. |
RemovedByUserId | Guid? | Нет | — | app | Кто отменил. |
Enum LeadBookingIntentSource
| Значение | Описание |
|---|---|
InvestorAdded | Инвестор сам добавил объект в намерения (Flow A) — ConfirmedByInvestor = true, ConfirmedAt = CreatedAt сразу. |
AgentProposed | Агент предложил объект / создал сделку по незапрошенному объекту (Flow B) — ConfirmedByInvestor = false до подтверждения инвестором. |
Enum LeadBookingIntentStatus
| Значение | Описание |
|---|---|
Active | Действующее намерение (RemovedAt is null); ровно одно на пару (LeadId,PropertyObjectId). |
Cancelled | Снято/отменено (инвестором или агентом). Историческая запись; допускается новое активное намерение на тот же объект. |
Переходы: Active → Active (подтверждение AgentProposed: ConfirmedByInvestor = false → true, ConfirmedAt = now), Active → Cancelled (снятие). Из Cancelled возврата нет — заводится новое намерение.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
ClientEntity | reference (родитель) | ClientId | загрузка при командах | restrict |
LeadParticipantEntity | owned-child (агрегат) | lead_participants.lead_id | загрузка состава при командах/карточке | cascade |
LeadBookingIntentEntity | owned-child (агрегат) | lead_booking_intents.lead_id | выборка активных при командах/карточке | cascade |
LeadEventEntity | owned-child (агрегат) | lead_events.lead_id | выборка с пагинацией (лента) | cascade |
FixationRequestEntity | reference (порождённые) | fixation_requests.lead_id | выборка по LeadId | restrict |
DealEntity | reference (порождённые) | deals.lead_id | выборка по LeadId | restrict |
ReferralLink (referral) | внешняя логическая | ReferralLinkId | не загружается (display id) | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-CRM-001 | На пару (ExecutorAgentUserId,PrimaryInvestorUserId) — не более одного лида в статусе Active; неактивных — сколько угодно | при создании/реактивации лида (unique partial ux_leads_active_pair) | CRM_LEAD_ACTIVE_PAIR_EXISTS |
| INV-LEAD-2 | События, фиксации и сделки создаются только у лида Status = Active | при добавлении события / создании фиксации или сделки | CRM_LEAD_NOT_ACTIVE |
| INV-LEAD-3 | Продление: ExpiresAt = max(now, ExpiresAt) + 1 месяц, ExtensionsCount += 1; продлевать можно Active и Expired (реактивация с повторной проверкой INV-CRM-001) | при продлении лида | CRM_LEAD_EXTEND_CONFLICT (реактивация нарушает INV-CRM-001) |
| INV-LEAD-4 | Перевод в Expired — только worker-ом при ExpiresAt < now и Status = Active; активные сделки лида при этом НЕ трогаются | фоновая задача lead-expiration (идемпотентно, ADR-0056) | — (идемпотентно) |
| INV-LEAD-5 | Wishes редактирует только участник лида агентской роли (или Менеджер ПИН); фиксация делает snapshot-override, не меняя лид | при редактировании хотелок | CRM_LEAD_FORBIDDEN |
| INV-LEAD-6 | Иммутабельны (set-once, record-level) только Origin: TenantId (= AgencyCompanyId) и OriginAgentUserId — не пересчитываются/не back-fill-ятся при смене агентом агентства/передаче ведения (ADR-0061 п.4, ADR-0071), сохраняется историческая воронка агентства. Текущий исполнитель (ExecutorAgentUserId) мутабелен — производная состава участников | при создании лида (правки Origin-полей нет) | CRM_LEAD_SNAPSHOT_IMMUTABLE |
| INV-LEAD-7 | Среди активных участников (RemovedAt is null): ровно один Executor, ровно один InvestorPrimary; InvestorSecondary/Responsible/CoAgent/Assistant/Lawyer — по составу; UserId уникален среди активных | при добавлении/удалении/смене роли участника (unique partial ux_lead_participants_active_user) | CRM_LEAD_PARTICIPANT_ROLE_CONFLICT / CRM_LEAD_PARTICIPANT_DUPLICATE |
| INV-LEAD-8 | Лид нельзя оставить без активного Executor: снятие/замена исполнителя — только атомарной передачей ведения (новый Executor назначается в той же операции); производные ExecutorAgentUserId/ResponsibleAgentUserId/PrimaryInvestorUserId пересчитываются в том же изменении | при смене состава участников / передаче ведения | CRM_LEAD_EXECUTOR_REQUIRED |
| INV-INTENT-1 | На пару (LeadId,PropertyObjectId) — не более одного намерения в статусе Active; снятых (Cancelled) — сколько угодно | при добавлении намерения (unique partial ux_lead_booking_intents_active_object) | CRM_INTENT_ACTIVE_OBJECT_EXISTS |
| INV-INTENT-2 | Намерения добавляются/подтверждаются/снимаются только у лида Status = Active | при командах над намерением | CRM_LEAD_NOT_ACTIVE |
| INV-INTENT-3 | При создании: InvestorAdded → ConfirmedByInvestor = true, ConfirmedAt = CreatedAt; AgentProposed → ConfirmedByInvestor = false, ConfirmedAt = null | при создании намерения | CRM_INTENT_INVALID_STATE |
| INV-INTENT-4 | Подтвердить намерение (ConfirmedByInvestor: false → true, ConfirmedAt = now) может только участник-инвестор пары; повторное подтверждение уже подтверждённого — no-op | при подтверждении намерения (Flow B) | CRM_INTENT_CONFIRM_FORBIDDEN |
| INV-INTENT-5 | PropertyObjectId/ProjectId/DeveloperCompanyId/Source — set-once снапшот из объекта, иммутабельны после создания | при создании намерения | CRM_INTENT_SNAPSHOT_IMMUTABLE |
| INV-INTENT-6 | DealId set-once (заполняется при порождении сделки из намерения); связь активного намерения и сделки — 1:1 | при связывании намерения со сделкой | CRM_INTENT_DEAL_ALREADY_LINKED |
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
LeadCreated | доменное | Создание лида | Id, TenantId, OriginAgentUserId, ExecutorAgentUserId, PrimaryInvestorUserId, Source |
LeadParticipantsChanged | доменное | Add/Remove/ChangeRole участника, передача ведения | LeadId, полный активный состав [{UserId, Role, IsPrimary}] |
LeadEventAdded | доменное | Добавление LeadEventEntity | LeadId, EventId, Type |
LeadBookingIntentChanged | доменное | Add/Confirm/Remove намерения бронирования | LeadId, IntentId, PropertyObjectId, Status, ConfirmedByInvestor |
LeadCreatedEvent | интеграционное, topic pin.crm.lead-created.v1, ContractVersion = 1 | Создание лида (для notifications — уведомление исполнителю; referral — подтверждение привязки) | LeadId, TenantId, OriginAgentUserId, ExecutorAgentUserId, PrimaryInvestorUserId, Source, CreatedAt; контракт — ../events/lead-created.md |
LeadParticipantsChangedEvent | интеграционное, topic pin.crm.lead-participants-changed.v1, ContractVersion = 1 | Add/Remove/ChangeRole участника / передача ведения | LeadId, TenantId, полный активный состав [{UserId, Role, IsPrimary}] + дельта {Added[], Removed[], RoleChanged[]}; консюмеры: messenger (синк участников чата лида ChatKind.Lead), notifications; контракт — ../events/lead-participants-changed.md |
LeadBookingIntentChangedEvent | интеграционное, topic pin.crm.lead-booking-intent-changed.v1, ContractVersion = 1 | Add/Confirm/Remove намерения бронирования | LeadId, TenantId, IntentId, PropertyObjectId, ProjectId, DeveloperCompanyId, Source, ConfirmedByInvestor, Status, ChangeKind {Added, Confirmed, Removed}; консюмеры: notifications (уведомление исполнителю о намерении инвестора / инвестору о предложении агента), messenger (по необходимости — карточка объекта в чате лида); контракт — ../events/lead-booking-intent-changed.md |
LeadExpiredEvent | интеграционное, topic pin.crm.lead-expired.v1, ContractVersion = 1 | Active → Expired worker-ом (уведомление исполнителю «продлите лид») | LeadId, TenantId, OriginAgentUserId, ExecutorAgentUserId, PrimaryInvestorUserId, ExpiredAt; контракт — ../events/lead-expired.md |
Интеграционные события публикуются атомарно со сменой состояния агрегата.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK; ix_leads_tenant_id; ix_leads_client_id; ix_leads_origin_agent_user_id; ix_leads_executor_agent_user_id; ux_leads_active_pair unique partial (executor_agent_user_id,primary_investor_user_id) where status='Active'; ix_leads_status; ix_leads_expires_at (worker); ix_leads_last_event_at desc; ix_leads_created_at; lead_participants: PK, ux_lead_participants_active_user unique partial (lead_id,user_id) where removed_at is null, ix_lead_participants_user_id; lead_booking_intents: PK, ux_lead_booking_intents_active_object unique partial (lead_id,property_object_id) where status='Active', ix_lead_booking_intents_tenant_id, ix_lead_booking_intents_property_object_id, ix_lead_booking_intents_developer_company_id, ix_lead_booking_intents_status, deal_id where not null; lead_events: PK, (lead_id,occurred_at desc), ux_lead_events_source_message_id unique partial |
| Партиционирование / retention | none; lead_events — прогноз < 10 млн строк/год на целевой нагрузке (ADR-0056), очистка не требуется; пересмотр объёма — при росте |
| Concurrency | оптимистическая блокировка; события — append-only |
| Permissions / PII | crm.leads.view / crm.leads.manage. Record-level по членству (ADR-0071): доступ = актор ∈ активные участники лида (любая роль состава) или администратор/руководитель компании-владельца (AgencyCompanyId = TenantId) в scope-иерархии (ADR-0025) или менеджер ПИН (platform-scope, US-MGR). Инвестор-участник видит без данных о фиксациях; застройщик не видит лиды вовсе. Доступ уходящего исполнителя снимается вместе с его членством, Origin-атрибуция при этом не трогается. PII в payload событий отсутствует (только id и агрегированные величины). |
| Audit | отметки создания/изменения/удаления (кто и когда), soft-delete; лента lead_events — append-only, не редактируется |
Кросс-сервисные контракты (что лид потребляет и предоставляет)
Лента лида наполняется преимущественно консюмерами — лид ожидает от соседей входные события и держит их как append-only факты.
| Входное событие | Источник | Эффект в лиде | Гарантия/идемпотентность |
|---|---|---|---|
pin.user-activity.property-object-view-ended.v1 | user-activity | LeadEventEntity PropertyObjectViewed | dedup по SourceMessageId (unique partial) |
pin.user-activity.action-tracked.v1 | user-activity | FilterApplied/FavoriteAdded | dedup по SourceMessageId |
pin.referral.lead-attachments.v1 | referral | привязка/создание лида Source = Referral, ReferralLinkId | at-least-once; повтор не создаёт второй активной пары (INV-CRM-001) |
pin.selections.selection-shared.v1 | selections | SelectionAttached/SelectionViewed | dedup по SourceMessageId |
pin.calendar.appointment-created/-confirmed/-cancelled/-completed.v1 | calendar | get-or-create клиента/лида (Source = Appointment) + записи ленты *Appointment* (calendar-appointments.md) | dedup по SourceMessageId + business-guard (AppointmentId, тип); контракты самодостаточны — обработка в любом кросс-топик порядке |
| lazy-регистрация пользователя | identity | добавление участника InvestorPrimary пары | лид создаётся только для (в т.ч. lazy) зарегистрированного пользователя |
Что лид предоставляет наружу: pin.crm.lead-created.v1 (→ notifications исполнителю; referral
подтверждение привязки), pin.crm.lead-participants-changed.v1 (→ messenger синк участников чата лида
ChatKind.Lead; notifications), pin.crm.lead-booking-intent-changed.v1 (→ notifications при
добавлении/подтверждении/снятии намерения; messenger по необходимости) и pin.crm.lead-expired.v1
(→ notifications «продлите лид»); ключ партиционирования = LeadId, ContractVersion = 1. PII в payload
этих событий отсутствует (только id).
Межсервисные последовательности и промежуточные состояния
Появление лида по заявке/рефералу/записи. Заявка catalog/front, событие referral/lazy-регистрация
или запись на слот календаря (pin.calendar.appointment-created.v1) → консюмер создаёт (или переиспользует
активный) лид пары «исполнитель ↔ основной инвестор» с начальным составом участников (Executor =
OriginAgentUserId + InvestorPrimary) и, при отсутствии, карточку клиента (client.md).
Порядок: сначала клиент, затем лид с составом участников, затем событие ленты — в одном согласованном
изменении на стороне crm. Далее асинхронно LeadCreatedEvent → notifications уведомляет исполнителя;
referral получает подтверждение привязки. Для записи из календаря ФИО/телефон клиента берутся из display-реплики identity
(calendar-appointments.md), дальнейший жизненный цикл встречи
(подтверждение, отмена, исход) пополняет ленту лида событиями pin.calendar.appointment-*.
:::note Окно eventual-consistency
Клиентская активность (просмотры/фильтры), произошедшая до создания лида, попадает в ленту по мере доставки
событий user-activity; порядок в ленте — по OccurredAt источника, а не по времени приёма, поэтому поздно
доставленное событие встаёт на свою историческую позицию.
:::
Продление и истечение. Worker lead-expiration при ExpiresAt < now и Status = Active переводит лид
в Expired (идемпотентно, INV-LEAD-4) и публикует LeadExpiredEvent → notifications. Активные сделки
лида при этом не трогаются. Продление (Extend) реактивирует Expired с повторной проверкой INV-CRM-001.
Отказы, идемпотентность и конкуренция
- Идемпотентность консюмеров. Каждый входной факт несёт
SourceMessageId(=MessageIdKafka-конверта), уникальный среди событий лида (unique partial) — повторная доставка не создаёт дублей ленты. - Единственность активной пары. INV-CRM-001 (unique partial
ux_leads_active_pairwherestatus='Active'): гонка двух источников, создающих лид для одной пары, → ровно одинActive; конкурент переиспользует его либо получаетCRM_LEAD_ACTIVE_PAIR_EXISTS. - Реактивация против нового лида. Если у пары уже есть
Active-лид,ExtendExpired-лида нарушил бы INV-CRM-001 →CRM_LEAD_EXTEND_CONFLICT; агент работает с существующим активным лидом. - Идемпотентность истечения. Повторный проход worker-а над уже
Expired-лидом — no-op, второгоLeadExpiredEventнет (ADR-0056). - Иммутабельный Origin-snapshot.
TenantId(=AgencyCompanyId)/OriginAgentUserId— set-once (INV-LEAD-6): смена агентом агентства или передача ведения не переписывают историческую воронку агентства; текущий исполнитель (ExecutorAgentUserId) при этом свободно меняется через состав участников (INV-LEAD-8). - Передача ведения без потери исполнителя. Снятие/замена
Executor— только атомарной операцией с назначением новогоExecutor(INV-LEAD-8); попытка оставить лид без исполнителя →CRM_LEAD_EXECUTOR_REQUIRED. ПроизводныеExecutorAgentUserId/ResponsibleAgentUserId/PrimaryInvestorUserIdпересчитываются в том же изменении и публикуются полным составом вLeadParticipantsChangedEvent.
Acceptance-критерии
- Happy (лид из заявки). Дано: инвестор оставил заявку на консультацию, активного лида пары нет. Когда
консюмер обрабатывает заявку. Тогда есть клиент, лид
Active(Source = ConsultationRequest), событие ленты, опубликованLeadCreatedEvent. - Повторная доставка события. Дано: событие просмотра уже записано (
SourceMessageIdизвестен). Когда оно доставляется повторно. Тогда новой строки ленты не появляется. - Активная пара уже есть. Дано:
Active-лид пары существует. Когда приходит новый источник на ту же пару. Тогда переиспользуется существующий лид (илиCRM_LEAD_ACTIVE_PAIR_EXISTS), второгоActiveнет. - Истечение не трогает сделки. Дано:
Active-лид с активной сделкой,ExpiresAt < now. Когда работает worker. Тогда лидExpired,LeadExpiredEventопубликован, сделка не меняется. - Реактивация с конфликтом. Дано:
Expired-лид и новыйActive-лид той же пары. Когда продлеваютExpired. Тогда отказCRM_LEAD_EXTEND_CONFLICT. - Правка хотелок чужим актором. Дано: актор — не участник лида агентской роли/не Менеджер ПИН. Когда
правит
Wishes. Тогда отказCRM_LEAD_FORBIDDEN; фиксация делает override-snapshot, лид не меняется. - Дубль участника/роли. Дано: у лида уже есть активный
Executor. Когда добавляют второгоExecutor(или второгоInvestorPrimary). Тогда отказCRM_LEAD_PARTICIPANT_ROLE_CONFLICT; тот жеUserIdповторно среди активных →CRM_LEAD_PARTICIPANT_DUPLICATE. - Передача ведения. Дано: лид с активным
ExecutorA. Когда руководитель передаёт ведение агенту B одной операцией. Тогда активныйExecutor— B,ExecutorAgentUserId = B, доступ A снят вместе с членством,OriginAgentUserIdне изменился, опубликованLeadParticipantsChangedEventс полным составом. - Нельзя оставить без исполнителя. Дано: лид с единственным активным
Executor. Когда его снимают без назначения нового. Тогда отказCRM_LEAD_EXECUTOR_REQUIRED, состав не меняется. - Смена агентства не искажает атрибуцию. Дано: исполнитель сменил агентство. Когда пересчитывается
атрибуция. Тогда
TenantId/OriginAgentUserIdлида не меняются (INV-LEAD-6), аExecutorAgentUserIdостаётся текущим исполнителем из состава. - Инвестор добавил намерение (Flow A). Дано:
Active-лид, активного намерения на объект нет. Когда инвестор добавляет объект в намерения. Тогда есть намерениеActive(Source = InvestorAdded,ConfirmedByInvestor = true,ConfirmedAt = CreatedAt), запись лентыBookingIntentAdded, опубликованLeadBookingIntentChangedEvent(ChangeKind = Added). - Дубль активного намерения. Дано: у пары (
LeadId,PropertyObjectId) уже естьActive-намерение. Когда добавляют второе на тот же объект. Тогда отказCRM_INTENT_ACTIVE_OBJECT_EXISTS. - Агент предложил, инвестор подтвердил (Flow B). Дано: агент создал намерение по незапрошенному объекту
(
Source = AgentProposed,ConfirmedByInvestor = false). Когда инвестор-участник подтверждает. ТогдаConfirmedByInvestor = true,ConfirmedAtзаполнен, запись лентыBookingIntentConfirmed, событиеLeadBookingIntentChangedEvent(ChangeKind = Confirmed). - Подтверждение чужим актором. Дано: неподтверждённое намерение
AgentProposed. Когда подтверждает не инвестор пары. Тогда отказCRM_INTENT_CONFIRM_FORBIDDEN. - Снятие намерения. Дано:
Active-намерение. Когда его снимают. ТогдаStatus = Cancelled,RemovedAtзаполнен, запись лентыBookingIntentRemoved; допускается новое активное намерение на тот же объект.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (8): Entity / Aggregate: BookingEntity, Entity / Aggregate: ChatEntity (+ParticipantEntity как owned-child), Entity / Aggregate: ClientEntity, Entity / Aggregate: DealEntity, Entity / Aggregate: DealTimelineEventEntity (TPH), Entity / Aggregate: FixationRequestEntity, Entity / Aggregate: PurchaseProgramEntity (+ InstallmentPresetEntity), Entity: ParticipantEntity (owned-child ChatEntity)
- API (39): DELETE /api/agent/crm/leads/{leadId}/participants/{userId} — Снять участника лида, DELETE /api/investor/crm/leads/{leadId}/booking-intents/{intentId} — Инвестор снимает объект-намерение, GET /api/agent/crm/clients — Клиентская база агента (список + поиск), GET /api/agent/crm/clients/{clientId} — Карточка клиента, GET /api/agent/crm/fixations — Список фиксаций агентства (+ поиск), GET /api/agent/crm/fixations/{fixationId} — Карточка фиксации (агент), GET /api/agent/crm/leads — Список лидов агента, GET /api/agent/crm/leads/{leadId} — Карточка лида, GET /api/agent/crm/leads/{leadId}/booking-intents — Список намерений лида, GET /api/agent/crm/leads/{leadId}/events — Лента событий лида, GET /api/agent/crm/statistics — Статистика агентства/агента, GET /api/developer/crm/fixations — Список фиксаций застройщика (+ FTS) (+27)
- Use Cases (18): UC-ACT-004. Агент/менеджер видит активность инвестора (для карточки лида), UC-BKG-001. Инвестор инициирует бронирование с витрины, UC-CRM-001. Создание лида (заявка / реферал / вручную), UC-CRM-002. Агент создаёт сделку из лида (выбор объекта, гейт вовлечения застройщика), UC-CRM-010. Назначение/снятие ответственного (куратора) процесса — ответственный ≠ исполнитель, UC-CRM-011. Лид с несколькими инвесторами: назначение основного инвестора, UC-CRM-012. Управление составом участников лида (добавление/снятие: второй агент, супруг/юрист инвестора), UC-CRM-013. Увольнение агента: каскад отвязки от процессов CRM, UC-CRM-014. Агент выбирает активное агентство перед созданием лида/сделки, UC-CRM-016. Инвестор добавляет намерения бронирования (много объектов), UC-CRM-017. Flow B: агент предлагает объект → Draft-сделка → подтверждение инвестором → вовлечение застройщика, UC-CRM-018. Несколько сделок из одной одобренной фиксации (по разным объектам застройщика) (+6)
- События (9): Event Contract: pin.calendar.appointment-created (AppointmentCreatedEvent), Event Contract: pin.crm.booking-cancelled (BookingCancelledEvent), Event Contract: pin.crm.booking-created (BookingCreatedEvent), Event Contract: pin.crm.deal-created (DealCreatedEvent), Event Contract: pin.crm.lead-booking-intent-changed (LeadBookingIntentChangedEvent), Event Contract: pin.crm.lead-created (LeadCreatedEvent), Event Contract: pin.crm.lead-expired (LeadExpiredEvent), Event Contract: pin.crm.lead-participants-changed (LeadParticipantsChangedEvent), Event Contract: pin.selections.selection-shared (SelectionSharedKafkaEvent)
- Консюмеры (7): Consumer: CRM Agent Offboarding (CrmAgentOffboardingConsumer), Consumer: CRM Calendar Appointments Sync (crm-calendar-appointments), Consumer: DevStatsFunnelLeadCreatedConsumer, Consumer: Lead Chat Participants Sync (MessengerLeadParticipantsChangedConsumer), Consumer: ReferralRegisteredConsumer (pin.referral.lead-attachments.v1), Consumer: Selection Shared (SelectionSharedConsumer), Consumer: User-Activity Actions (crm-user-activity-actions)
- Фоновые задачи (1): Background Job: LeadExpirationJob
- adr (4): ADR-0061: Независимый агент, отвязка от агентства и приглашения по номеру телефона, ADR-0070: Мульти-агентство и контекст активного агентства, ADR-0071: Владение процессами CRM компанией и единая модель участников, ADR-0072: Увольнение/исключение агента и каскад доступа
- index.md (1): CRM Service
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- client.md — родительская карточка; fixation-request.md,
deal.md — порождаемые процессы; purchase-program.md — enum
PaymentMethod. - Консюмеры-источники событий лида: user-activity-actions.md, referral-registered.md, calendar-appointments.md.
- Справочные ссылки хотелок (
BudgetCurrencyId/CityId+CityName): snapshots.md, правка — update-lead-wishes.md. - Единая модель участников и
ParticipantRole— deal.md (состав сделки). - ADR-0052 (tenancy), ADR-0056 (polling-expiration), ADR-0048 (RUB-only),
ADR-0070 (мульти-агентство,
активное агентство), ADR-0071
(владение процессами компанией, Origin/Executor,
ParticipantRole, видимость по составу), ADR-0072 (увольнение агента, каскад членства).