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

Entity / Aggregate: AgentMembershipEntity

Дом полей агрегата «Членство агента в агентстве» контекста identity (ADR-0061, ADR-0070, ADR-0072). Все проекции (payload события pin.identity.agent-membership-changed.v1, модели ответов API accept/become-agent/leave-agency/terminate-agent-membership) ссылаются сюда и перечисляют только дельту.

Назначение

AgentMembershipEntityразвязываемая связь «агент ↔ агентство» с полной историей вступлений и выходов (аналогия «игрок в нескольких командах»). По решению ADR-0061 членство моделируется отдельным агрегатом, а не nullable CompanyId на UserEntity: у агента много membership-записей во времени. По ADR-0070 агент может состоять сразу в нескольких агентствах — допускается несколько Active-записей одновременно, но не более одной на пару (AgentUserId, CompanyId) (INV-AM1 SingleActivePerCompany).

  • Независимый агент = пользователь c ролью agent (в scope Platform, см. role.md) и без единой активной membership (ноль Active-записей). Он существует вне агентства, сохраняет UserId и все метрики/достижения.
  • Выход (leave — добровольный self-leave, либо terminate — принудительное исключение руководителем/админом агентства, ADR-0072) не удаляет учётку и не переписывает историю: запись membership переводится в Left (LeftAt заполняется, причина фиксируется в LeaveReason), а не удаляется. Агент может выйти из одного агентства, оставаясь активным в других, и позже вступить в новое агентство новой membership-записью.
  • UserEntity.CompanyId для агента — денормализованное зеркало текущего выбранного активного агентства (RefreshSession.ActiveCompanyId, ADR-0070), а не «единственного» членства и не источник истины членства; при нескольких Active-записях указывает на выбранное активное агентство, null у независимого. Source of truth и полная история членств — здесь (user.md, ослабленный INV-2).

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

ПолеЗначение
Контекстidentity
KindAggregateRoot (своя консистентность, события, история)
Source of truthidentity
Таблицаidentity.agent_memberships
Tenant isolationTenantId = CompanyId (агентство владеет своим ростером); агент видит собственные membership сквозь компании — record-level по AgentUserId (метрики user-centric, ADR-0061 п.5)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор записи членства.
AgentUserIdGuidДаFK fk_agent_memberships_user → identity.users(id), index ix_agent_memberships_user_idappАгент (учётка UserType = Agent).
CompanyIdGuidДаFK fk_agent_memberships_company → identity.companies(id), index ix_agent_memberships_company_idappАгентство (TenantId).
StatusAgentMembershipStatusДаvarchar(16), indexappActive / Left. Значение под увольнение не вводится (ADR-0072): факт увольнения несёт LeaveReason, не новое состояние.
LeaveReasonLeaveReason?Нетvarchar(16); заполняется вместе с Status = LeftappПричина завершения: SelfLeave (добровольный уход) / ExcludedByAgency (принудительное исключение). null пока Active.
JoinedAtDateTimeOffsetДаindex ix_agent_memberships_joined_atappМомент вступления.
LeftAtDateTimeOffset?Нетзаполняется вместе с Status = Left, > JoinedAtappМомент, с которого членство недействительно (EffectiveAt события; для отложенного увольнения ≠ штампу изменения); null пока Active.
InvitationIdGuid?НетFK → identity.company_invitations(id)appПриглашение, породившее вступление (company-invitation.md); null при вступлении без приглашения.
CreatedAtDateTimeOffsetДаappШтамп создания.
CreatedByUserIdGuid?НетappКто инициировал вступление.
UpdatedAtDateTimeOffsetДаappШтамп изменения.
UpdatedByUserIdGuid?НетappКто инициировал выход: сам агент (self-leave) или руководитель/админ агентства (terminate).
VersionlongДа>= 1appДоменная версия.

Enum AgentMembershipStatus

ЗначениеОписание
ActiveДействующее членство: агент работает в агентстве. Не более одной такой записи на пару (AgentUserId, CompanyId) (INV-AM1 SingleActivePerCompany), но у агента может быть несколько Active-записей по разным агентствам (ADR-0070); UserEntity.CompanyId зеркалит выбранное активное агентство.
LeftЗавершённое членство: LeftAt заполнен, причина в LeaveReason, история найма сохранена, комиссии/сделки прошлого периода остаются у компании (снапшоты crm, ADR-0061 п.4). Отдельного значения под увольнение нет — принудительное исключение отличается от добровольного ухода только LeaveReason (ADR-0072).

Enum LeaveReason

ЗначениеОписание
SelfLeaveДобровольный уход — действие самого агента (endpoint leave, инициатор — владелец учётки).
ExcludedByAgencyПринудительное исключение — действие руководителя/админа агентства (endpoint terminate-agent-membership, permission identity.users.manage).

Value objects / EF owned types

Owned-VO нет.

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

JSONB-модели не используются.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
UserEntity (агент)referenceAgentUserId (FK)GetById(AgentUserId) в join/leaverestrict
CompanyEntity (агентство)referenceCompanyId (FK)GetById(CompanyId) — Active при вступленииrestrict
CompanyInvitationEntityreferenceInvitationId (FK, nullable)FindById при вступлении по приглашениюrestrict

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

ИнвариантУсловиеГде проверяетсяОшибка
INV-AM1 SingleActivePerCompanyНе более одной Active записи на пару (AgentUserId, CompanyId); несколько Active-записей по разным агентствам допускаются (ADR-0070)unique partial index ux_agent_memberships_active_agent_company on (agent_user_id, company_id) where status = 'Active' + Any(spec) по паре до вставкиIDENTITY_AGENT_ALREADY_IN_AGENCY
INV-AM2 LeaveKeepsHistoryLeave/terminate только меняет Status = Left + LeftAt + LeaveReason; запись не удаляется и не переписываетсяметод завершения членства; репозиторий без Delete
INV-AM3 LeftAtConsistencyStatus = Left ⇔ LeftAt != null ∧ LeaveReason != null, LeftAt > JoinedAtguard при завершении членстваIDENTITY_MEMBERSHIP_INVALID_STATE
INV-AM4 AgentTypeRequiredAgentUserId — учётка с UserType = Agent (для инвестора вступление идёт через become-agent, повышающий тип)join-handlerIDENTITY_USER_TYPE_COMPANY_MISMATCH
INV-AM5 CompanyIsAgencyCompanyId.Type ∈ {Agency, Broker, Partner} и Status = ActiveGetById(CompanyId)IDENTITY_USER_TYPE_COMPANY_MISMATCH / IDENTITY_COMPANY_SUSPENDED
INV-AM6 MirrorConsistencyЗеркало UserEntity.CompanyId = выбранное активное агентство (RefreshSession.ActiveCompanyId, ADR-0070): открытие/закрытие Active синхронно поддерживает зеркало и scope роли agent (Organization пока есть ≥1 Active, Platform при нулевом активном членстве) в той же транзакции; завершение выбранного активного агентства сбрасывает зеркало (перевыбор через POST /api/v1/me/active-agency), закрытие невыбранного — не трогает зеркалоjoin/leave/terminate-handler (мульти-агрегатная транзакция)

Независимый агент — это отсутствие Active-записи; отдельного статуса «независимый» нет.

Пред/постусловия и интеграция

  • Join / Accept-invitation (открытие Active). Предусловие: цель — UserType = Agent (INV-AM4; инвестор проходит через become-agent, который сначала повышает тип, затем в том же согласованном изменении открывает членство и назначает роль — промежуточного состояния «тип повышен, членства нет» наружу не видно); компания-агентство Active и Type ∈ {Agency, Broker, Partner} (INV-AM5); нет активного membership по этой паре (AgentUserId, CompanyId) — активные членства в других агентствах не мешают (INV-AM1 SingleActivePerCompany). Постусловие: ровно одна Active-запись по паре, роль agent в scope Organization; если это первое активное агентство агента — зеркало UserEntity.CompanyId = CompanyId (INV-AM6), иначе выбор активного агентства не меняется; ушли AgentJoinedAgencyEvent + AgentMembershipChangedKafkaEvent.
  • Self-leave (Active → Left, добровольный уход). Инициатор — сам агент (владелец учётки, ADR-0072). Предусловие guard: агент не является единственным активным Executor ни на одном активном лиде/сделке/ фиксации этого агентства без назначенного преемника (иначе отказ IDENTITY_AGENT_HAS_ACTIVE_PROCESSES со списком процессов под reassign — инвариант ADR-0071 «процесс нельзя оставить без Executor»; каскад — ADR-0072). Постусловие: Status = Left, LeftAt = EffectiveAt, LeaveReason = SelfLeave (INV-AM3); если это было выбранное активное агентство — зеркало сброшено (перевыбор через POST /api/v1/me/active-agency); роль agent перевыдана Organization → Platform только если не осталось ни одной Active-записи (иначе scope сохраняется); ушли AgentLeftAgencyEvent + changed-событие (Reason = SelfLeave, EffectiveAt). Историческая привязка сделок/лидов/фиксаций к компании (снапшоты crm) не пересчитывается (ADR-0061 п.4).
  • Terminate (Active → Left, принудительное исключение). Инициатор — руководитель или админ агентства (permission identity.users.manage, record-level по членству своего агентства, ADR-0072), endpoint terminate-agent-membership. Тот же guard «единственный активный Executor» — увольнение блокируется до переназначения ExecutorAgentUserId на процессах агента; отказ возвращает список процессов под reassign. Постусловие идентично self-leave, но LeaveReason = ExcludedByAgency; каскад доступа (soft-remove участий crm/messenger, перенос BookingDeadline/встреч, revoke RoleAssignment в rewards) — по событию членства (ADR-0072).
Конкурентность

Две одновременные попытки открыть Active по одной паре (повторный accept одного агентства) — ux_agent_memberships_active_agent_company пропускает одну; проигравший получает IDENTITY_AGENT_ALREADY_IN_AGENCY (или CONCURRENCY_CONFLICT при гонке версий). Вступления в разные агентства не конфликтуют. Одновременные join и leave/terminate по одной паре сериализуются тем же индексом и оптимистической блокировкой.

Свежесть у потребителей

crm/rewards/notifications пересчитывают ростер и user-centric метрики по AgentMembershipChangedKafkaEvent (key = AgentUserId, порядок per-agent) в окне eventual-consistency; в промежутке зеркало UserEntity.CompanyId и ростер компании у потребителя могут кратко расходиться со снапшотом события — последнее по порядку побеждает.

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

СобытиеТипКогдаSnapshot/поля
AgentJoinedAgencyEventIDomainEventОткрытие Active (accept/join)Id, AgentUserId, CompanyId, InvitationId?
AgentLeftAgencyEventIDomainEventActive → Left (self-leave/terminate)Id, AgentUserId, CompanyId, LeftAt, LeaveReason
AgentMembershipChangedKafkaEventIKafkaEvent (ContractVersion = 1, topic pin.identity.agent-membership-changed.v1, key = AgentUserId)Любой переход членства (join / self-leave / terminate)MembershipId, AgentUserId, CompanyId, Status, JoinedAt, LeftAt?, Reason? (LeaveReason, additive), EffectiveAt? (additive), Version — контракт agent-membership-changed.md. Reason/EffectiveAt — additive, ContractVersion не бампится (ADR-0072); увольнение НЕ вводит новое значение Status.

Ключ события — AgentUserId (порядок per-agent): потребители (crm, rewards, notifications) пересчитывают ростер и user-centric метрики. rewards при мульти-членстве апсертит AgentPerformance по паре (AgentUserId, CompanyId) (ADR-0070); при Reason = ExcludedByAgency/SelfLeave запускается каскад доступа (ADR-0072): crm — soft-remove участий и reassign Executor, messenger — soft-remove участника чатов, rewards — read-only-история и revoke RoleAssignment. Историческая привязка сделок/лидов/фиксаций не пересчитывается — снапшоты AgencyCompanyId/OriginAgencyCompanyId/ OriginAgentUserId в crm иммутабельны, back-fill запрещён (ADR-0061 п.4).

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

АспектЗначение
ИндексыPK (Id); ux_agent_memberships_active_agent_company unique partial (agent_user_id, company_id) where status='Active' (INV-AM1 SingleActivePerCompany); ix_agent_memberships_user_id; ix_agent_memberships_company_id; ix_agent_memberships_joined_at
Партиционирование / retentionnone; история членства — бессрочно (основание найма/комиссий)
Concurrencyоптимистическая блокировка (правка не должна затирать чужую); одновременные join/leave/terminate по одной паре (agent_user_id, company_id) сериализуются ux_agent_memberships_active_agent_company
Permissions / PIIPII нет (только id-ссылки); ростер компании — identity.users.view в scope компании; собственные membership агента — record-level по AgentUserId; self-leave — self-действие агента; terminate — identity.users.manage руководителя/админа агентства (record-level по членству своего агентства, ADR-0072)
AuditVersion + actor-stamping (CreatedBy/UpdatedBy); полная история (записи не удаляются)

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

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

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