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

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
KindAggregateRoot (дочерние LeadParticipantEntity — состав участников, LeadBookingIntentEntity — намерения бронирования, LeadEventEntity — лента, TPH)
Source of truthcrm
Таблицаcrm.leads (+ crm.lead_participants, + crm.lead_booking_intents, + crm.lead_events, TPH)
Tenant isolationTenantId = AgencyCompanyId (ADR-0052: «агентство для лида»); совпадает с Origin-агентством (ADR-0071)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор лида.
TenantIdGuidДаindex ix_leads_tenant_ididentity (actor) / consumer привязкиКомпания агентства (или ПИН) = AgencyCompanyId = Origin-агентство (кто привёл клиента). Record-level snapshot, иммутабелен (ADR-0061 п.4, ADR-0071, INV-LEAD-6).
ClientIdGuidДаFK fk_leads_client_id → crm.clients(id), index ix_leads_client_idapp / consumerКарточка клиента (client.md); создаётся вместе с первым лидом, если отсутствовала.
OriginAgentUserIdGuidДаindex ix_leads_origin_agent_user_idapp / consumer привязкиАтрибуция: агент, создавший лид («кто привёл клиента»). Для UC-2 — случайный сотрудник компании ПИН (ТЗ.md:529). Record-level snapshot, иммутабелен (ADR-0061 п.4, ADR-0071, INV-LEAD-6): не переписывается при смене агентом агентства/передаче ведения — основа исторической воронки агентства, оснований комиссии и антифрода.
ExecutorAgentUserIdGuidДаindex ix_leads_executor_agent_user_id; часть ux_leads_active_pairderived (участник Executor)Текущий исполнитель лида — денормализованная проекция активного участника с ролью Executor (получатель комиссии). Мутабельна: меняется при передаче ведения, поддерживается атомарно со сменой состава участников. Единственный источник истины исполнителя — участник Executor (см. LeadParticipantEntity); отдельного скаляра-владельца нет.
ResponsibleAgentUserIdGuid?Нетindex where not nullderived (участник Responsible)Ответственный/куратор ведения — денормализованная проекция активного участника с ролью Responsible; опциональна, может ≠ ExecutorAgentUserId. Мутабельна.
PrimaryInvestorUserIdGuidДаindex; часть ux_leads_active_pairderived (участник InvestorPrimary)Основной инвестор пары — денормализованная проекция активного участника с ролью InvestorPrimary. Инвесторы лида ведутся составом участников (InvestorPrimary ровно один + InvestorSecondary — сколько угодно). Лид создаётся только для зарегистрированного (в т.ч. lazy) пользователя.
StatusLeadStatusДа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.
WishesLeadWishes (owned)Да (все поля VO nullable)см. VO нижеapp (форма квалификации агента) / consumer заявки«Шапка хотелок» инвестора; сохраняется в лиде, фиксация делает только override-снимок.
SourceLeadSourceДаenum как text, varchar(32)consumer / appКак появился лид (см. enum). Иммутабельно.
ReferralLinkIdGuid?Нетindex where not null; логическая ссылка на referralconsumer pin.referral.*Реферальная ссылка, породившая привязку (если была).
ExpiresAtDateTimeOffsetДаindex ix_leads_expires_atapp: CreatedAt + 1 месяц; продление +1 месяцСрок жизни лида («~месяц, продлевается»). Проверяется polling-worker-ом (ADR-0056), не TTL-магией.
ExtensionsCountintДа>= 0, default 0appСколько раз лид продлевался (ExtendLead).
LastExtendedAtDateTimeOffset?НетappМомент последнего продления (агентом или авто-продления по активности клиента).
LastEventAtDateTimeOffset?Нетindex ix_leads_last_event_at descapp (при добавлении LeadEventEntity)Последнее событие в ленте — сортировка списка лидов.
ExpiredAtDateTimeOffset?Нетbackground jobФактический момент перевода в Expired.
ArchivedAtDateTimeOffset?НетappМомент ручного архивирования.
ArchiveReasonstring?Нетvarchar(1000)appПричина архивирования (агент указал).
CreatedAtDateTimeOffsetДаindex ix_leads_created_atappСоздание лида.
CreatedByUserIdGuid?Нетappnull — создан консюмером (заявка/реферал); иначе агент.
UpdatedAtDateTimeOffsetДаappДата последнего изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?НетappSoft-delete (административно).
DeletedByUserIdGuid?Нет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.

ПолеТипОбязательностьОграниченияКолонка
BudgetNullableRange<decimal> (owned VO)Нетnumeric(18,2) на границу, >= 0, Min <= Maxwishes_budget_min / wishes_budget_max
BudgetCurrencyIdshort?Неттипизированная ссылка на справочник валют handbook (без FK); не задан → трактуется как RUB (RUB-only, ADR-0048); валидация на write-path — internal resolve (неизвестная/архивная валюта → отказ валидации, update-lead-wishes.md); display-код — из снапшотов/lookup справочника (snapshots.md)wishes_budget_currency_id
CityIdlong?Неттипизированная ссылка на справочник городов handbook (без FK); валидация на write-path — internal resolve, неизвестный/архивный город → CRM_CITY_UNKNOWN (update-lead-wishes.md)wishes_city_id
CityNamestring?Нетvarchar(256); денормализованное display-имя города, снятое из resolve при записи (при переименовании в справочнике не «догоняется» — живёт до следующей правки хотелок); заполнено только вместе с CityIdwishes_city_name
UrgencyLeadUrgency?Нетenum как text, varchar(32)wishes_urgency
PurchaseGoalPurchaseGoal?Нетenum как text, varchar(32)wishes_purchase_goal
PreferredPaymentMethodPaymentMethod?Нетenum как text, varchar(32); enum — purchase-program.mdwishes_preferred_payment_method
Commentstring?Нет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) строятся на этом составе, а не на скаляре.

ПолеТипОбязательностьОграниченияИсточникОписание
IdGuidДаPKappИдентификатор участия.
LeadIdGuidДаFK fk_lead_participants_lead_id → crm.leads(id) cascade; unique partial ux_lead_participants_active_user (LeadId,UserId) where removed_at is nullappЛид.
TenantIdGuidДаindexиз лидаДублируется для tenant-фильтра без join.
UserIdGuidДаindex ix_lead_participants_user_id; логическая ссылка на identity.users(id)appПользователь-участник.
RoleParticipantRoleДаenum как text, varchar(32)appРоль участника (см. enum). Меняется операцией смены роли участника.
IsPrimaryboolДаdefault falseappПризнак «основной» в своей роли (основной инвестор InvestorPrimary / первичный исполнитель); поддерживает денормализацию производных лида.
AddedByUserIdGuid?НетappКто добавил (null — система: авто-состав при создании лида).
CreatedAtDateTimeOffsetДаappМомент добавления.
RemovedAtDateTimeOffset?НетappSoft-удаление участия (история для чата/аудита; активные участники — RemovedAt is null).
RemovedByUserIdGuid?Нет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).

ПолеТипОбязательностьОграниченияИсточникОписание
IdGuidДаPKappИдентификатор события.
LeadIdGuidДаFK fk_lead_events_lead_id → crm.leads(id) cascade, index ix_lead_events_lead_id_occurred_at (LeadId,OccurredAt desc)appРодительский лид.
TenantIdGuidДаindexиз лидаДублируется для tenant-фильтра без join.
TypeLeadEventTypeДаdiscriminator, enum как text, varchar(64)appTPH-тип (см. enum).
OccurredAtDateTimeOffsetДаconsumer (время источника) / appКогда событие произошло у источника.
SourceMessageIdGuid?Нетunique ux_lead_events_source_message_id where not nullconsumerMessageId Kafka-конверта — идемпотентность консюмеров.
Payloadtyped jsonb per-typeДаjsonb, модели ниже, ContractVersionconsumer / appТипизированные данные события.
CreatedAtDateTimeOffsetДаappЗапись в ленту.

Enum LeadEventType и typed payload (jsonb, ContractVersion = 1)

ТипКогдаPayload-модель (все поля обязательны, если не помечено ?)
PropertyObjectViewedconsumer pin.user-activity.property-object-view-ended.v1LeadPropertyObjectViewedPayload { PropertyObjectId: Guid, ProjectId: Guid?, DurationSeconds: int (>=0), SessionId: Guid }
FilterAppliedconsumer 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)
FavoriteAddedconsumer 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? }
ShowingAppointmentCreatedconsumer pin.calendar.appointment-created.v1 (Kind = Showing) — инвестор записался на показLeadAppointmentPayload (общая модель шести appointment-типов, см. ниже)
ConsultationAppointmentCreatedconsumer pin.calendar.appointment-created.v1 (Kind = Consultation; fail-safe для неизвестного Kind) — инвестор записался на консультациюLeadAppointmentPayload
AppointmentConfirmedconsumer pin.calendar.appointment-confirmed.v1 — встреча подтверждена (агентом или auto-confirm)LeadAppointmentPayload (AutoConfirmed заполнен)
AppointmentCancelledconsumer pin.calendar.appointment-cancelled.v1 — запись отклонена/отменена/просрочена до встречиLeadAppointmentPayload (Reason заполнен; RescheduledToAppointmentId — перенос, не разрыв)
AppointmentCompletedconsumer pin.calendar.appointment-completed.v1 (Outcome = Completed; fail-safe для неизвестного Outcome) — встреча состояласьLeadAppointmentPayload
AppointmentNoShowconsumer pin.calendar.appointment-completed.v1 (Outcome = NoShow) — клиент не пришёл (сигнал повторной связи)LeadAppointmentPayload

LeadAppointmentPayload — общая payload-модель appointment-типов

Заполняется консюмером calendar-appointments.md из контрактов pin.calendar.appointment-*.v1; обязательные поля есть во всех четырёх контрактах, опциональные — только в указанных типах.

ПолеТипОбязательностьЗаполняется уОписание
AppointmentIdGuidДавсех типовЗапись в calendar — бизнес-ключ идемпотентности ленты (не более одной записи на пару AppointmentId + тип).
KindstringДавсех типовВид встречи (Showing/Consultation, enum EventKind контекста calendar); неизвестное значение сохраняется строкой как есть.
StartAtDateTimeOffsetДавсех типовНачало встречи (UTC, иммутабельный снапшот слота).
ProjectIdGuid?Нет*AppointmentCreatedПроект застройщика (chessboard, логическая ссылка) — контекст показа.
PropertyObjectIdGuid?Нет*AppointmentCreatedОбъект недвижимости показа (chessboard, логическая ссылка).
Commentstring?Нет*AppointmentCreatedКомментарий инвестора при записи (≤ 1024); класс Internal — маскирование в логах, без индексации в поиске.
AutoConfirmedbool?НетAppointmentConfirmedtrue — подтверждение при бронировании (auto-confirm слота), false — агент подтвердил вручную.
Reasonstring?НетAppointmentCancelledПричина (enum AppointmentCancelReason контекста calendar: DeclinedByAgent/CancelledByInvestor/CancelledByAgent/Expired); неизвестное значение — строкой как есть (нейтральное «запись отменена»).
RescheduledFromAppointmentIdGuid?Нет*AppointmentCreatedПрежняя запись атомарной пары переноса — запись ленты трактуется как продолжение, а не новая заявка.
RescheduledToAppointmentIdGuid?Нет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).

ПолеТипОбязательностьОграниченияИсточникОписание
IdGuidДаPKappИдентификатор намерения.
LeadIdGuidДа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Родительский лид.
TenantIdGuidДаindex ix_lead_booking_intents_tenant_idиз лидаКомпания-агентство (= AgencyCompanyId); дублируется для tenant-фильтра без join.
PropertyObjectIdGuidДаindex ix_lead_booking_intents_property_object_id; логическая ссылка на chessboardapp / consumerОбъект недвижимости намерения.
ProjectIdGuidДалогическая ссылка на chessboardapp / consumerПроект застройщика объекта (контекст). Snapshot из объекта.
DeveloperCompanyIdGuidДаindex ix_lead_booking_intents_developer_company_idapp / consumerЗастройщик — владелец объекта; пара (инвестор × застройщик) определяет требуемую фиксацию для гейта сделки. Snapshot из объекта.
SourceLeadBookingIntentSourceДаenum как text, varchar(32)appКто инициировал намерение (см. enum). Иммутабельно.
ConfirmedByInvestorboolДаdefault falseappПодтверждено инвестором. InvestorAddedtrue сразу; AgentProposedfalse до подтверждения инвестором.
ConfirmedAtDateTimeOffset?НетappМомент подтверждения инвестором (для InvestorAdded = CreatedAt).
StatusLeadBookingIntentStatusДаenum как text, varchar(32), index ix_lead_booking_intents_statusappСм. enum и переходы.
DealIdGuid?Нетindex where not null; логическая ссылка на crm.deals(id)appПорождённая из намерения сделка (1:1 активного намерения). null — сделки пока нет.
AddedByUserIdGuid?НетappКто добавил (инвестор или агент); null — консюмер заявки.
CreatedAtDateTimeOffsetДаappМомент создания намерения.
RemovedAtDateTimeOffset?НетappSoft-снятие (Status = Cancelled); активные — RemovedAt is null.
RemovedByUserIdGuid?Нет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
ClientEntityreference (родитель)ClientIdзагрузка при командахrestrict
LeadParticipantEntityowned-child (агрегат)lead_participants.lead_idзагрузка состава при командах/карточкеcascade
LeadBookingIntentEntityowned-child (агрегат)lead_booking_intents.lead_idвыборка активных при командах/карточкеcascade
LeadEventEntityowned-child (агрегат)lead_events.lead_idвыборка с пагинацией (лента)cascade
FixationRequestEntityreference (порождённые)fixation_requests.lead_idвыборка по LeadIdrestrict
DealEntityreference (порождённые)deals.lead_idвыборка по LeadIdrestrict
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-5Wishes редактирует только участник лида агентской роли (или Менеджер ПИН); фиксация делает 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При создании: InvestorAddedConfirmedByInvestor = true, ConfirmedAt = CreatedAt; AgentProposedConfirmedByInvestor = 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-5PropertyObjectId/ProjectId/DeveloperCompanyId/Source — set-once снапшот из объекта, иммутабельны после созданияпри создании намеренияCRM_INTENT_SNAPSHOT_IMMUTABLE
INV-INTENT-6DealId 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доменноеДобавление LeadEventEntityLeadId, 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 = 1Add/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 = 1Add/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 = 1Active → 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
Партиционирование / retentionnone; lead_events — прогноз < 10 млн строк/год на целевой нагрузке (ADR-0056), очистка не требуется; пересмотр объёма — при росте
Concurrencyоптимистическая блокировка; события — append-only
Permissions / PIIcrm.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.v1user-activityLeadEventEntity PropertyObjectVieweddedup по SourceMessageId (unique partial)
pin.user-activity.action-tracked.v1user-activityFilterApplied/FavoriteAddeddedup по SourceMessageId
pin.referral.lead-attachments.v1referralпривязка/создание лида Source = Referral, ReferralLinkIdat-least-once; повтор не создаёт второй активной пары (INV-CRM-001)
pin.selections.selection-shared.v1selectionsSelectionAttached/SelectionVieweddedup по SourceMessageId
pin.calendar.appointment-created/-confirmed/-cancelled/-completed.v1calendarget-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. Далее асинхронно LeadCreatedEventnotifications уведомляет исполнителя; 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) и публикует LeadExpiredEventnotifications. Активные сделки лида при этом не трогаются. Продление (Extend) реактивирует Expired с повторной проверкой INV-CRM-001.

Отказы, идемпотентность и конкуренция

  • Идемпотентность консюмеров. Каждый входной факт несёт SourceMessageId (= MessageId Kafka-конверта), уникальный среди событий лида (unique partial) — повторная доставка не создаёт дублей ленты.
  • Единственность активной пары. INV-CRM-001 (unique partial ux_leads_active_pair where status='Active'): гонка двух источников, создающих лид для одной пары, → ровно один Active; конкурент переиспользует его либо получает CRM_LEAD_ACTIVE_PAIR_EXISTS.
  • Реактивация против нового лида. Если у пары уже есть Active-лид, Extend Expired-лида нарушил бы 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.
  • Передача ведения. Дано: лид с активным Executor A. Когда руководитель передаёт ведение агенту 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; допускается новое активное намерение на тот же объект.

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

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

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

  • 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.
  • Единая модель участников и ParticipantRoledeal.md (состав сделки).
  • ADR-0052 (tenancy), ADR-0056 (polling-expiration), ADR-0048 (RUB-only), ADR-0070 (мульти-агентство, активное агентство), ADR-0071 (владение процессами компанией, Origin/Executor, ParticipantRole, видимость по составу), ADR-0072 (увольнение агента, каскад членства).