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(в scopePlatform, см. 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 |
| Kind | AggregateRoot (своя консистентность, события, история) |
| Source of truth | identity |
| Таблица | identity.agent_memberships |
| Tenant isolation | TenantId = CompanyId (агентство владеет своим ростером); агент видит собственные membership сквозь компании — record-level по AgentUserId (метрики user-centric, ADR-0061 п.5) |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор записи членства. |
AgentUserId | Guid | Да | FK fk_agent_memberships_user → identity.users(id), index ix_agent_memberships_user_id | app | Агент (учётка UserType = Agent). |
CompanyId | Guid | Да | FK fk_agent_memberships_company → identity.companies(id), index ix_agent_memberships_company_id | app | Агентство (TenantId). |
Status | AgentMembershipStatus | Да | varchar(16), index | app | Active / Left. Значение под увольнение не вводится (ADR-0072): факт увольнения несёт LeaveReason, не новое состояние. |
LeaveReason | LeaveReason? | Нет | varchar(16); заполняется вместе с Status = Left | app | Причина завершения: SelfLeave (добровольный уход) / ExcludedByAgency (принудительное исключение). null пока Active. |
JoinedAt | DateTimeOffset | Да | index ix_agent_memberships_joined_at | app | Момент вступления. |
LeftAt | DateTimeOffset? | Нет | заполняется вместе с Status = Left, > JoinedAt | app | Момент, с которого членство недействительно (EffectiveAt события; для отложенного увольнения ≠ штампу изменения); null пока Active. |
InvitationId | Guid? | Нет | FK → identity.company_invitations(id) | app | Приглашение, породившее вступление (company-invitation.md); null при вступлении без приглашения. |
CreatedAt | DateTimeOffset | Да | — | app | Штамп создания. |
CreatedByUserId | Guid? | Нет | — | app | Кто инициировал вступление. |
UpdatedAt | DateTimeOffset | Да | — | app | Штамп изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Кто инициировал выход: сам агент (self-leave) или руководитель/админ агентства (terminate). |
Version | long | Да | >= 1 | app | Доменная версия. |
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 (агент) | reference | AgentUserId (FK) | GetById(AgentUserId) в join/leave | restrict |
CompanyEntity (агентство) | reference | CompanyId (FK) | GetById(CompanyId) — Active при вступлении | restrict |
CompanyInvitationEntity | reference | InvitationId (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 LeaveKeepsHistory | Leave/terminate только меняет Status = Left + LeftAt + LeaveReason; запись не удаляется и не переписывается | метод завершения членства; репозиторий без Delete | — |
| INV-AM3 LeftAtConsistency | Status = Left ⇔ LeftAt != null ∧ LeaveReason != null, LeftAt > JoinedAt | guard при завершении членства | IDENTITY_MEMBERSHIP_INVALID_STATE |
| INV-AM4 AgentTypeRequired | AgentUserId — учётка с UserType = Agent (для инвестора вступление идёт через become-agent, повышающий тип) | join-handler | IDENTITY_USER_TYPE_COMPANY_MISMATCH |
| INV-AM5 CompanyIsAgency | CompanyId.Type ∈ {Agency, Broker, Partner} и Status = Active | GetById(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-AM1SingleActivePerCompany). Постусловие: ровно однаActive-запись по паре, рольagentв scopeOrganization; если это первое активное агентство агента — зеркало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, принудительное исключение). Инициатор — руководитель или админ агентства (permissionidentity.users.manage, record-level по членству своего агентства, ADR-0072), endpoint terminate-agent-membership. Тот же guard «единственный активныйExecutor» — увольнение блокируется до переназначенияExecutorAgentUserIdна процессах агента; отказ возвращает список процессов под reassign. Постусловие идентично self-leave, ноLeaveReason = ExcludedByAgency; каскад доступа (soft-remove участий crm/messenger, переносBookingDeadline/встреч, revokeRoleAssignmentв 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/поля |
|---|---|---|---|
AgentJoinedAgencyEvent | IDomainEvent | Открытие Active (accept/join) | Id, AgentUserId, CompanyId, InvitationId? |
AgentLeftAgencyEvent | IDomainEvent | Active → Left (self-leave/terminate) | Id, AgentUserId, CompanyId, LeftAt, LeaveReason |
AgentMembershipChangedKafkaEvent | IKafkaEvent (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 |
| Партиционирование / retention | none; история членства — бессрочно (основание найма/комиссий) |
| Concurrency | оптимистическая блокировка (правка не должна затирать чужую); одновременные join/leave/terminate по одной паре (agent_user_id, company_id) сериализуются ux_agent_memberships_active_agent_company |
| Permissions / PII | PII нет (только id-ссылки); ростер компании — identity.users.view в scope компании; собственные membership агента — record-level по AgentUserId; self-leave — self-действие агента; terminate — identity.users.manage руководителя/админа агентства (record-level по членству своего агентства, ADR-0072) |
| Audit | Version + actor-stamping (CreatedBy/UpdatedBy); полная история (записи не удаляются) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (4): Entity / Aggregate: CompanyInvitationEntity, Entity / Aggregate: RoleDefinition (+ RolePermission, RoleAssignment / UserRole, ResourceScope), Entity / Aggregate: SignInConfirmationRequest + RefreshSession, Entity / Aggregate: UserEntity
- API (9): Identity Agent API — реестр методов и общие правила области, POST /api/v1/companies/{companyId}/agents/{agentUserId}/terminate — Исключение агента из агентства, POST /api/v1/me/active-agency — Выбор активного агентства, POST /api/v1/me/agency-membership/leave — Выход агента из агентства, POST /api/v1/me/agent-invitations/{invitationId}/accept — Принятие приглашения в агентство, POST /api/v1/me/become-agent — Инвестор становится агентом, POST /api/v1/me/become-investor — Агент возвращается к роли инвестора, PUT /api/agent/crm/{deals|leads}/{id}/executor — Передать ведение (сменить исполнителя), PUT /api/agent/crm/{deals|leads}/{id}/responsible — Назначить/снять ответственного (куратора)
- Use Cases (9): UC-CRM-009. Передача ведения сделки (смена исполнителя), UC-CRM-010. Назначение/снятие ответственного (куратора) процесса — ответственный ≠ исполнитель, UC-CRM-013. Увольнение агента: каскад отвязки от процессов CRM, UC-CRM-014. Агент выбирает активное агентство перед созданием лида/сделки, UC-CRM-015. Явное назначение исполнителя для разрешения спора о комиссии, UC-IDN-009. Приглашение агента по телефону и его принятие, UC-IDN-010. Агент выходит из агентства, сохраняя метрики, UC-IDN-011. Инвестор становится агентом, US-реестр «Участники и владение процессами» (US-CRM-PART / US-AG-ADMIN / US-AGT-MULTI / US-INV-CO)
- События (1): Event Contract: pin.identity.agent-membership-changed (AgentMembershipChangedKafkaEvent)
- Консюмеры (1): Consumer: Identity Agent Membership Changed
- index.md (1): Identity Service (Pin.Identity)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- user.md (ослабленный INV-2, зеркало
CompanyId= выбранное активное агентство, метрики user-centric), role.md (рольagent:Organization↔Platform, become-agent/become-investor), company-invitation.md (accept порождает membership). - API: accept-invitation.md,
become-agent.md,
leave-agency.md (
self-leave), become-investor.md; terminate-agent-membership (принудительное исключение,identity.users.manage) и POST /api/v1/me/active-agency (выбор активного агентства при мульти-членстве). - Событие: agent-membership-changed.md.
- Историческая привязка (reference only, не редактируется): deal.md, lead.md, fixation-request.md; статистика — get-statistics.md.
- Решение: ADR-0061 (независимый
агент, membership), ADR-0070
(мульти-агентство,
SingleActivePerCompany, активное агентство), ADR-0072 (self-leave/terminate,LeaveReason, guardExecutor, каскад доступа); шаблон: entity-template.md.