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

Entity / Aggregate: OwnerMappingEntity

Дом полей агрегата «Сопоставление Собственник ↔ Инвестор» контекста investor-owner. Все проекции (payload события pin.investor-owner.owner-linked.v1, модели admin-API разрешения конфликтов, ссылки OwnerMappingId в investor-property.md) ссылаются сюда и описывают только дельту.

Назначение

OwnerMappingEntity фиксирует связь между собственником из внешней системы Talent Owners (OwnerEntity) и пользователем-инвестором PIN (UserEntity контекста identity):

  • сопоставление выполняется напрямую по PhoneNumber (нормализация E.164);
  • если инвестора с таким телефоном ещё нет — пользователь-инвестор создаётся в identity на основе OwnerEntity, при этом id собственника становится id пользователя-инвестора (id собственника = id пользователя-инвестора) — правило INV-OWN-001;
  • если инвестор с таким телефоном уже существует — связь устанавливается на существующего пользователя (его Id мог быть выдан раньше и не равен OwnerId; расхождение фиксируется полем LinkKind).

Агрегат — точка идемпотентности consumer-а синхронизации Talent Owners (../consumers/talent-owners-sync.md): повторное событие по тому же OwnerId — upsert, а не дубль. Он же — реестр конфликтов (два собственника с одним телефоном, невалидный номер), которые разрешает Служба заботы через admin-API.

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

ПолеЗначение
Контекстinvestor-owner
KindAggregateRoot (связка-реплика: снапшот-поля собственника владеет Talent Owners, состояние связи — этот контекст)
Source of truthсвязь и её статус — investor-owner; снапшот-поля собственника (OwnerFullName, PhoneNumber) — Talent Owners; учётка инвестора — identity
Таблицаinvestor_owner.owner_mappings
Tenant isolationне tenanted: данные инвестора — вне tenant (ADR-0052); изоляция — record-level по InvestorUserId

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKTalent Owners (OwnerEntity.Id)Идентификатор связи = id собственника в Talent Owners. По INV-OWN-001 при создании нового инвестора он же становится UserEntity.Id в identity.
InvestorUserIdGuid?Нет (обязателен при Status = Linked)unique ux_owner_mappings_investor_user_id where investor_user_id is not null and deleted_at is null; ссылка на identity.users(id) без FK (чужой контекст)identity (ответ internal-API provision-investor)Пользователь-инвестор PIN. Равен Id при LinkKind = InvestorCreated; может отличаться при ExistingInvestorMatched. null, пока связь не установлена.
StatusOwnerMatchStatusДахранится как text (varchar(32)), index ix_owner_mappings_statusappPendingLink / Linked / Conflict (см. enum ниже).
LinkKindOwnerLinkKind?Нет (обязателен при Status = Linked)хранится как text (varchar(32))appКак установлена связь: создан новый инвестор или сопоставлен существующий. Иммутабельно после установки.
PhoneNumberstringДаvarchar(32), нормализация E.164; unique ux_owner_mappings_phone_number where deleted_at is null and status <> 'Conflict'; index ix_owner_mappings_phone_numberTalent Owners (OwnerEntity.PhoneNumber)Телефон собственника — ключ сопоставления. Второй собственник с тем же телефоном получает Status = Conflict (INV-4).
OwnerFullNameFullName (VO)Дасм. VO нижеTalent OwnersФИО собственника (снапшот). Передаётся в identity при создании инвестора; у существующего инвестора имя не перезаписывается.
ConflictReasonOwnerLinkConflictReason?Нет (обязателен при Status = Conflict)хранится как text (varchar(64))appМашиночитаемая причина конфликта (см. enum ниже).
ConflictDetailsstring?Нетvarchar(512)appЧеловекочитаемая детализация для Службы заботы (например, id конкурирующей связи). Без raw PII третьих лиц — телефон маскируется.
LinkedAtDateTimeOffset?Нет (обязателен при Status = Linked)appМомент установки связи (успешный ответ identity / найден существующий инвестор).
ResolvedByUserIdGuid?Нетadmin-APIСотрудник заботы, вручную разрешивший конфликт; null при автоматической связи.
ReplicaVersionlongДа>= 0Talent Owners (событие)Версия последнего применённого события синхронизации; out-of-order guard (INV-6).
LastSyncedAtDateTimeOffsetДаconsumerВремя последнего применённого события Talent Owners.
CreatedAtDateTimeOffsetДаindex ix_owner_mappings_created_atappСоздание связи (первое событие синхронизации по этому собственнику).
CreatedByUserIdGuid?Нетappnull — создано consumer-ом; иначе id актора admin-API.
UpdatedAtDateTimeOffsetДаappМомент последнего изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?НетappSoft-delete (собственник удалён в Talent Owners / ручной архив).
DeletedByUserIdGuid?НетappКто архивировал.
VersionlongДа>= 1appДоменная версия связи (растёт при каждом изменении; входит в payload события).

Enum OwnerMatchStatus

ЗначениеОписание
PendingLinkЗапись создана из события Talent Owners; пользователь-инвестор ещё не найден/не создан (например, identity недоступен — consumer повторит).
LinkedСвязь установлена: InvestorUserId заполнен, LinkKind зафиксирован, объекты собственника привязываются к инвестору.
ConflictАвтоматическая связь невозможна; требуется ручное разрешение Службой заботы (admin-API).

Enum OwnerLinkKind

ЗначениеОписание
InvestorCreatedИнвестора с телефоном не было; пользователь создан в identity c Id = OwnerId (INV-OWN-001), RegisteredVia = LazyRegistration.
ExistingInvestorMatchedИнвестор с таким телефоном уже существовал; InvestorUserId = его Id (может не совпадать с OwnerId).

Enum OwnerLinkConflictReason

ЗначениеОписание
PhoneAlreadyMappedToAnotherOwnerТелефон уже связан с другим собственником (ux_owner_mappings_phone_number).
PhoneInvalidНомер из Talent Owners не нормализуется в E.164.
MatchedUserNotInvestorПользователь с таким телефоном в identity существует, но Type != Investor (менеджер/агент/сотрудник ПИН).
IdentityIdCollisionВ identity уже есть пользователь с Id = OwnerId, но с другим телефоном — создать инвестора с требуемым id нельзя.

Value objects

FullName (контракт совпадает с identity../../identity/domain/user.md)

ПолеТипОбязательностьОграниченияКолонка
FirstNamestringДаvarchar(128), min 1owner_full_name_first_name
LastNamestringДа (пустая строка допустима, если Talent Owners прислал только имя)varchar(128)owner_full_name_last_name
MiddleNamestring?Нетvarchar(128)owner_full_name_middle_name

Модели JSONB

JSONB-модели не используются: все поля связи — плоские колонки; сырой payload события Talent Owners не сохраняется (при спорах — replay топика).

Связи

СвязьТипКлючПравило загрузкиDelete behavior
UserEntity (identity)reference (чужой контекст, без FK)InvestorUserId → identity.users(id)не загружается напрямую; создание/поиск — internal-API identity; снапшот-обновления — consumer pin.identity.users.v1— (eventual)
InvestorPropertyEntityreference (обратная)investor_properties.owner_mapping_id → owner_mappings(id)выборка по OwnerMappingId в consumer-е объектов Talent Ownersrestrict (объекты портфеля не удаляются с связью; при soft-delete связи остаются у InvestorUserId)
OwnerEntity (Talent Owners)replicaId = OwnerIdconsumer читает по Id при каждом событии синхронизации

Ожидания от источников (реплики и соседние контракты)

Связь — реплика-агрегат: часть полей приходит извне, поэтому фиксируем контрактные ожидания к источникам и поведение при их нарушении/устаревании.

  • Talent Owners → событие собственника (вход синхронизации). Ожидается: OwnerId стабилен и иммутабелен (ключ связи Id = OwnerId); Version монотонно растёт per-owner (на этом стоит INV-6 — событие со «старой» версией идемпотентно пропускается); PhoneNumber присутствует и в норме нормализуем в E.164 (иначе — Conflict(PhoneInvalid), INV-8). Порядок событий одного собственника гарантирован ключом OwnerId. Отсутствие события по собственнику = отсутствие связи (фантомных записей не создаётся).
  • identity → provision-investor (синхронный вызов при линковке). Вход: телефон (E.164), FullName = OwnerFullName, requiredId = OwnerId. Ответ — один из: инвестор найден по телефону (ExistingInvestorMatched), инвестор создан с Id = OwnerId (InvestorCreated, гарантия INV-OWN-001 — при Id != OwnerId линковка отвергается как дефект → INVESTOR_OWNER_ID_MISMATCH), найден пользователь-не-инвестор (MatchedUserNotInvestor) или коллизия id (IdentityIdCollision). При недоступности identity связь остаётся PendingLink (не Conflict) — линковка повторяется, данные не теряются.
  • identity → pin.identity.users.v1 (асинхронно, контроль расхождений). Смена телефона уже связанного инвестора не разрывает связь: сопоставление по телефону выполняется только в момент линковки; расхождение (PhoneNumber связи ≠ актуальный телефон инвестора) фиксируется алертом, InvestorUserId и LinkKind не переписываются (INV-7).
  • Устаревшая/отсутствующая реплика. Снапшот OwnerFullName/PhoneNumber может отставать на окно синка (лаг мониторится owners_sync_lag_seconds); связь и её статус — локальный source of truth, лаг реплики на них не влияет. Состав и проданность объектов приходят отдельным потоком владений (investor-property.md), не через этот агрегат.

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

ИнвариантУсловиеКогда проверяетсяОшибка
INV-OWN-001 IdEqualityПри LinkKind = InvestorCreated пользователь создаётся в identity с Id = OwnerId, поэтому InvestorUserId = Idпри линковке новосозданного инвестора (guard investorUserId == Id)INVESTOR_OWNER_ID_MISMATCH
INV-2 LinkedCompletenessStatus = Linked ⇒ InvestorUserId != null ∧ LinkKind != null ∧ LinkedAt != nullпри переходе в Linked (единственные пути)INVESTOR_OWNER_LINK_INCOMPLETE
INV-3 ConflictCompletenessStatus = Conflict ⇒ ConflictReason != nullпри переводе связи в ConflictINVESTOR_OWNER_CONFLICT_REASON_REQUIRED
INV-4 UniquePhoneОдин телефон — одна не-конфликтная связьunique ux_owner_mappings_phone_number + проверка по PhoneNumber в consumer-е до вставкиINVESTOR_OWNER_PHONE_ALREADY_MAPPEDConflict(PhoneAlreadyMappedToAnotherOwner)
INV-5 UniqueInvestorОдин инвестор — не более одной связи с собственникомunique ux_owner_mappings_investor_user_idINVESTOR_OWNER_INVESTOR_ALREADY_MAPPED
INV-6 MonotonicReplicaСобытие синхронизации применяется только при event.Version > ReplicaVersionconsumer (шаг валидации)идемпотентный пропуск, не ошибка
INV-7 ImmutableLinkKindПосле Linked поля LinkKind, InvestorUserId не меняются (перепривязка — только admin-операция заботы с аудитом)гарантируется доменом (изменение запрещено)INVESTOR_OWNER_LINK_IMMUTABLE
INV-8 PhoneFormatPhoneNumber нормализован в E.164 до сохранениявалидация входа consumer-а; ненормализуемый номер → Conflict(PhoneInvalid)ValidationError

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

СобытиеТипКогдаSnapshot/поля
OwnerMappingLinkedдоменноеПереход PendingLink/Conflict → LinkedId, InvestorUserId, LinkKind
OwnerMappingConflictDetectedдоменноеПереход PendingLink → ConflictId, ConflictReason
InvestorOwnerLinkedKafkaEventинтеграционное (ContractVersion = 1, topic pin.investor-owner.owner-linked.v1, key = Id, AggregateType = OwnerMapping)Успешный переход в LinkedOwnerId (= Id), InvestorUserId, LinkKind, LinkedAt, Version. Телефон в payload не передаётся (PII; потребителям достаточно id).

Потребители pin.investor-owner.owner-linked.v1: notifications (приветственное СМС/уведомление новому инвестору-собственнику), profile (создание профиля при LinkKind = InvestorCreated). Интеграционные события публикуются атомарно со сменой состояния связи. Контракт — ../events/owner-linked.md.

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

АспектЗначение
ИндексыPK (Id); ux_owner_mappings_investor_user_id unique where not null and deleted_at is null; ux_owner_mappings_phone_number unique where deleted_at is null and status <> 'Conflict'; ix_owner_mappings_phone_number btree; ix_owner_mappings_status; ix_owner_mappings_created_at
Партиционирование / retentionnone (≤ 1 млн строк на горизонте ADR-0056); soft-delete, физическое удаление PII — по регламенту администратора платформы (ADR-0053)
Concurrencyоптимистическая блокировка (правка не должна затирать чужую); гонка двух consumer-инстансов по одному телефону разрешается unique-индексами (проигравший — Conflict)
Permissions / PIIPhoneNumber, OwnerFullName — PII: маскирование в логах (+7***1234); чтение своей связи — record-level InvestorUserId = actor; список/разрешение конфликтов — permission investor-owner.mappings.manage (платформенный scope, Служба заботы); телефон не попадает в Kafka payload
Auditотметки создания/изменения/удаления; ручные разрешения конфликтов — ResolvedByUserId + доменное событие

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

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

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