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 |
| Kind | AggregateRoot (связка-реплика: снапшот-поля собственника владеет 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) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | Talent Owners (OwnerEntity.Id) | Идентификатор связи = id собственника в Talent Owners. По INV-OWN-001 при создании нового инвестора он же становится UserEntity.Id в identity. |
InvestorUserId | Guid? | Нет (обязателен при 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, пока связь не установлена. |
Status | OwnerMatchStatus | Да | хранится как text (varchar(32)), index ix_owner_mappings_status | app | PendingLink / Linked / Conflict (см. enum ниже). |
LinkKind | OwnerLinkKind? | Нет (обязателен при Status = Linked) | хранится как text (varchar(32)) | app | Как установлена связь: создан новый инвестор или сопоставлен существующий. Иммутабельно после установки. |
PhoneNumber | string | Да | varchar(32), нормализация E.164; unique ux_owner_mappings_phone_number where deleted_at is null and status <> 'Conflict'; index ix_owner_mappings_phone_number | Talent Owners (OwnerEntity.PhoneNumber) | Телефон собственника — ключ сопоставления. Второй собственник с тем же телефоном получает Status = Conflict (INV-4). |
OwnerFullName | FullName (VO) | Да | см. VO ниже | Talent Owners | ФИО собственника (снапшот). Передаётся в identity при создании инвестора; у существующего инвестора имя не перезаписывается. |
ConflictReason | OwnerLinkConflictReason? | Нет (обязателен при Status = Conflict) | хранится как text (varchar(64)) | app | Машиночитаемая причина конфликта (см. enum ниже). |
ConflictDetails | string? | Нет | varchar(512) | app | Человекочитаемая детализация для Службы заботы (например, id конкурирующей связи). Без raw PII третьих лиц — телефон маскируется. |
LinkedAt | DateTimeOffset? | Нет (обязателен при Status = Linked) | — | app | Момент установки связи (успешный ответ identity / найден существующий инвестор). |
ResolvedByUserId | Guid? | Нет | — | admin-API | Сотрудник заботы, вручную разрешивший конфликт; null при автоматической связи. |
ReplicaVersion | long | Да | >= 0 | Talent Owners (событие) | Версия последнего применённого события синхронизации; out-of-order guard (INV-6). |
LastSyncedAt | DateTimeOffset | Да | — | consumer | Время последнего применённого события Talent Owners. |
CreatedAt | DateTimeOffset | Да | index ix_owner_mappings_created_at | app | Создание связи (первое событие синхронизации по этому собственнику). |
CreatedByUserId | Guid? | Нет | — | app | null — создано consumer-ом; иначе id актора admin-API. |
UpdatedAt | DateTimeOffset | Да | — | app | Момент последнего изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший. |
DeletedAt | DateTimeOffset? | Нет | — | app | Soft-delete (собственник удалён в Talent Owners / ручной архив). |
DeletedByUserId | Guid? | Нет | — | app | Кто архивировал. |
Version | long | Да | >= 1 | app | Доменная версия связи (растёт при каждом изменении; входит в 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)
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
FirstName | string | Да | varchar(128), min 1 | owner_full_name_first_name |
LastName | string | Да (пустая строка допустима, если Talent Owners прислал только имя) | varchar(128) | owner_full_name_last_name |
MiddleName | string? | Нет | 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) |
InvestorPropertyEntity | reference (обратная) | investor_properties.owner_mapping_id → owner_mappings(id) | выборка по OwnerMappingId в consumer-е объектов Talent Owners | restrict (объекты портфеля не удаляются с связью; при soft-delete связи остаются у InvestorUserId) |
OwnerEntity (Talent Owners) | replica | Id = OwnerId | consumer читает по 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 LinkedCompleteness | Status = Linked ⇒ InvestorUserId != null ∧ LinkKind != null ∧ LinkedAt != null | при переходе в Linked (единственные пути) | INVESTOR_OWNER_LINK_INCOMPLETE |
| INV-3 ConflictCompleteness | Status = Conflict ⇒ ConflictReason != null | при переводе связи в Conflict | INVESTOR_OWNER_CONFLICT_REASON_REQUIRED |
| INV-4 UniquePhone | Один телефон — одна не-конфликтная связь | unique ux_owner_mappings_phone_number + проверка по PhoneNumber в consumer-е до вставки | INVESTOR_OWNER_PHONE_ALREADY_MAPPED → Conflict(PhoneAlreadyMappedToAnotherOwner) |
| INV-5 UniqueInvestor | Один инвестор — не более одной связи с собственником | unique ux_owner_mappings_investor_user_id | INVESTOR_OWNER_INVESTOR_ALREADY_MAPPED |
| INV-6 MonotonicReplica | Событие синхронизации применяется только при event.Version > ReplicaVersion | consumer (шаг валидации) | идемпотентный пропуск, не ошибка |
| INV-7 ImmutableLinkKind | После Linked поля LinkKind, InvestorUserId не меняются (перепривязка — только admin-операция заботы с аудитом) | гарантируется доменом (изменение запрещено) | INVESTOR_OWNER_LINK_IMMUTABLE |
| INV-8 PhoneFormat | PhoneNumber нормализован в E.164 до сохранения | валидация входа consumer-а; ненормализуемый номер → Conflict(PhoneInvalid) | ValidationError |
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
OwnerMappingLinked | доменное | Переход PendingLink/Conflict → Linked | Id, InvestorUserId, LinkKind |
OwnerMappingConflictDetected | доменное | Переход PendingLink → Conflict | Id, ConflictReason |
InvestorOwnerLinkedKafkaEvent | интеграционное (ContractVersion = 1, topic pin.investor-owner.owner-linked.v1, key = Id, AggregateType = OwnerMapping) | Успешный переход в Linked | OwnerId (= 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 |
| Партиционирование / retention | none (≤ 1 млн строк на горизонте ADR-0056); soft-delete, физическое удаление PII — по регламенту администратора платформы (ADR-0053) |
| Concurrency | оптимистическая блокировка (правка не должна затирать чужую); гонка двух consumer-инстансов по одному телефону разрешается unique-индексами (проигравший — Conflict) |
| Permissions / PII | PhoneNumber, OwnerFullName — PII: маскирование в логах (+7***1234); чтение своей связи — record-level InvestorUserId = actor; список/разрешение конфликтов — permission investor-owner.mappings.manage (платформенный scope, Служба заботы); телефон не попадает в Kafka payload |
| Audit | отметки создания/изменения/удаления; ручные разрешения конфликтов — ResolvedByUserId + доменное событие |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: InvestorPropertyEntity
- API (4): GET /api/v1/admin/owner-mappings/conflicts — Очередь конфликтов связи Собственник ↔ Инвестор (listOwnerMappingConflicts), GET /api/v1/portfolio/properties/{propertyId} — Карточка объекта портфеля (getMyProperty), Investor-Owner API — реестр операций и общие контрактные модели, POST /api/v1/admin/owner-mappings/{mappingId}/resolve — Ручное разрешение конфликта связи (resolveOwnerMappingConflict)
- Use Cases (1): UC-OWN-001. Синхронизация собственника из Talent Owners: owner → investor по телефону (system)
- События (3): Event Contract: pin.investor-owner.owner-linked (InvestorOwnerLinkedKafkaEvent), Event Contract: pin.investor-owner.property-added (InvestorPropertyAddedKafkaEvent), Event Contract: pin.investor-owner.property-sold (InvestorPropertySoldKafkaEvent)
- Консюмеры (2): Consumer: контроль смены телефона связанного инвестора (pin-investor-owner-identity-users), Consumer: синхронизация из Talent Owners (pin-investor-owner-talent-owners-sync)
- index.md (1): Сервис Инвестор-Собственник (investor-owner)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- investor-property.md — объекты портфеля, привязанные к связи.
- Consumer синхронизации: ../consumers/talent-owners-sync.md.
- Событие связи: ../events/owner-linked.md; admin-разрешение конфликта: ../api/admin/resolve-owner-mapping-conflict.md.
- Создание инвестора: internal-API
identity— ../../identity/api/provision-investor.md; учётка — ../../identity/domain/user.md. - Решения: ADR-0052 (инвестор вне tenant), ADR-0056 (simplicity-first).