UC-OWN-001. Синхронизация собственника из Talent Owners: owner → investor по телефону (system)
Назначение
Описывает системный сценарий синхронизации: внешняя система Talent Owners публикует события
собственников недвижимости и их владений, а сервис investor-owner заводит и поддерживает связь
собственник→инвестор (OwnerMappingEntity). Если инвестора в PIN ещё нет, сервис инициирует его создание
в identity по номеру телефона (PhoneNumber), причём идентификатор нового инвестора совпадает с
идентификатором собственника (правило INV-OWN-001), и реплицирует объекты владения в портфель инвестора
(InvestorPropertyEntity, Source = TalentOwnersSync). Запускается автоматически при поступлении событий
Talent Owners; результат виден инвестору в разделе «Мои объекты недвижимости». Связан с контекстом
investor-owner.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-OWN-001 |
| Контекст | investor-owner (см. docs/06-services/investor-owner/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | System (системный сценарий синхронизации, system-actor; не пользовательский контекст) |
| Permissions / scope | permissions не применяются: доступ к топикам talent.owners.* и вызов internal-API identity provision-investor — межсервисные (m2m); мутации выполняются под system-actor (без владельца, CreatedByUserId = null) |
| Tenant | не применяется: связь собственника и записи портфеля — вне tenant (ADR-0052) |
| Record-level | не применяется на записи; InvestorUserId проставляется по данным связи |
Предусловие
- Событийная шина доступна; сервис подписан на топики Talent Owners
talent.owners.owners.v1иtalent.owners.ownerships.v1(имена топиков — зафиксированное предположение до подтверждения реестром схем Talent, см. README «Известные Ограничения»). - Хранилище контекста
investor-ownerразвёрнуто (связи собственниковowner_mappings, портфельinvestor_properties). - Internal-API
identityprovision-investorдоступен; при его недоступности связь остаётся в статусеPendingLink, событие повторяется (A1).
Триггер
| Поле | Значение |
|---|---|
| Источник | Kafka event |
| Триггерящий элемент | Событие собственника (OwnerUpsertedEvent/OwnerDeletedEvent) или владения (OwnershipUpsertedEvent) из Talent Owners; обработка — ../consumers/talent-owners-sync.md (будущий путь) |
Основной Поток
- Talent Owners публикует событие собственника
OwnerUpsertedEvent(снапшот:OwnerId,PhoneNumber,FirstName,LastName,MiddleName,Version) в топикtalent.owners.owners.v1. - Сервис
investor-ownerпринимает событие собственника:- Идемпотентность: повторная доставка того же события (по
EventId) подтверждается без эффекта; обработка идёт под system-actor. - Проверка входа:
OwnerIdзадан,PhoneNumberнепустой,FirstNameне короче 1 символа; номер нормализуется в формат E.164 — если нормализация невозможна, связь помечается конфликтомPhoneInvalid(INV-8, A3). Version-guard (INV-6): событие применяется только если егоVersionбольше версии реплики (ReplicaVersion); иначе — идемпотентный пропуск и метрикаowners_sync_stale_events_total. - Ищем существующую связь собственника по
OwnerId(её может ещё не быть — первое событие) и проверяем, не занят ли нормализованный телефон другой активной связью (INV-4 UniquePhone). - Применяем правила и маппинг:
- связи нет → создаём связь: телефон — нормализованный,
OwnerFullNameизFirstName/LastName/MiddleName, статусPendingLink,ReplicaVersionиз события,LastSyncedAt— время события; - связь есть → обновляем снапшот из источника: перезаписываются только синк-владеемые поля
PhoneNumber,OwnerFullName,ReplicaVersion,LastSyncedAt; локальные поля связи (InvestorUserId,LinkKind,Status) не трогаются (INV-7); - нормализованный телефон уже связан с другим собственником → связь помечается конфликтом
PhoneAlreadyMappedToAnotherOwner(INV-3, INV-4), линковка не выполняется; - иначе, если статус
PendingLink— линковка: межсервисный вызовidentityprovision-investor(phoneNumber, fullName = OwnerFullName, requiredId = OwnerId); по ответу:Found { UserId, UserType = Investor }→ связь переходит вLinked,InvestorUserId = UserId,LinkKind = ExistingInvestorMatched,LinkedAt— текущее время (INV-2);Created { UserId }→ проверяемUserId == OwnerId(INV-OWN-001; при несовпадении — ошибкаINVESTOR_OWNER_ID_MISMATCH, событие в DLQ); связь переходит вLinked,InvestorUserId = OwnerId,LinkKind = InvestorCreated,LinkedAt— текущее время; вidentityсоздан инвесторUserEntity(Id = OwnerId, Type = Investor, RegisteredVia = LazyRegistration);Found { UserType != Investor }→ конфликтMatchedUserNotInvestor;IdCollision(вidentityуже есть пользовательId = OwnerIdс другим телефоном) → конфликтIdentityIdCollision;
- при переходе в
Linkedфиксируется доменный фактOwnerMappingLinked, при любом из конфликтов —OwnerMappingConflictDetected.
- связи нет → создаём связь: телефон — нормализованный,
- Согласованность: изменение связи, аудит/версионирование и факт линковки/конфликта фиксируются в одном
согласованном изменении. Уникальные индексы
ux_owner_mappings_phone_number/ux_owner_mappings_investor_user_id— последний рубеж гонки двух параллельных обработчиков (проигравший ловит конфликт вставки и уходит в конфликтную запись). - События: при переходе в
Linkedпубликуется интеграционное событиеInvestorOwnerLinkedKafkaEvent(топикpin.investor-owner.owner-linked.v1, ключ иAggregateId = Id; payload:OwnerId(=Id),InvestorUserId,LinkKind,LinkedAt,Version; телефон в payload НЕ передаётся — PII). - Результат: событие подтверждается (ack).
- Идемпотентность: повторная доставка того же события (по
- Talent Owners публикует событие владения
OwnershipUpsertedEvent(снапшот:OwnershipId,OwnerId, атрибуты объекта недвижимости,PurchasePrice,PurchasedAt, опциональноSalePrice/SoldAt,Version) в топикtalent.owners.ownerships.v1. - Сервис
investor-ownerпринимает событие владения:- Идемпотентность по
EventId. - Проверка: связь собственника по
OwnerIdнайдена и её статусLinked— иначе retriable-отказINVESTOR_OWNER_MAPPING_NOT_LINKED(INV-8: событие ждёт в retry с экспоненциальным backoff до установки связи → DLQ; не теряется; включает и случай, когда связь есть, но «застряла» вConflict— объекты применятся после ручного разрешения A5).PurchasePrice.Amount > 0(INV-4, иначеINVESTOR_OWNER_PRICE_INVALID); коды валют Talent резолвятся вCurrencyIdпо справочнику валют handbook (пустой/неизвестный код → валюта по умолчаниюRUB+ warn, ADR-0048 — правило резолва в ../consumers/talent-owners-sync.md); при наличии продажи:SalePrice.Amount > 0(иначеINVESTOR_OWNER_PRICE_INVALID, INV-4),SoldAt >= PurchasedAt(иначеINVESTOR_OWNER_SALE_DATA_INCOMPLETE, INV-3),SalePrice.CurrencyId == PurchasePrice.CurrencyId(иначеINVESTOR_OWNER_CURRENCY_MISMATCH, INV-5). - Ищем запись портфеля по внешнему идентификатору владения
ExternalOwnershipId(может ещё не быть — идемпотентность синка объектов, INV-7 investor-property). - Применяем правила и маппинг:
- записи нет → создаём запись портфеля из данных синка:
InvestorUserIdиз связи,OwnerMappingId,ExternalOwnershipId = OwnershipId,PropertyObjectId(ProjectId/BuildingId/DeveloperId = null, если объект не сопоставлен с шахматкой PIN), снапшот объекта из payload (SnapshotAt— время события),PurchasePrice,PurchasedAt,Source = TalentOwnersSync,Status = Active(INV-2 SourceConsistency); если payload уже содержит продажу — тем же событием фиксируется продажа:RealizedProfit.Amount = SalePrice.Amount − PurchasePrice.Amount,Status = Sold(переход[*] → Sold); - запись есть и payload принёс продажу, а запись
Active→ фиксируется продажа («сколько и когда заработали»:RealizedProfit,SoldAt,Status = Sold); поля покупки не перезаписываются (INV-9); - запись есть, изменений нет → идемпотентный no-op.
- записи нет → создаём запись портфеля из данных синка:
- Согласованность: запись портфеля и факт её создания/продажи фиксируются в одном согласованном
изменении; уникальные индексы
ux_investor_properties_external_ownership_idиux_investor_properties_investor_property_object_active(INV-1) гасят гонки. - События: при создании —
InvestorPropertyAddedKafkaEvent→pin.investor-owner.property-added.v1(payload:Id,InvestorUserId,PropertyObjectId,ProjectId,Source,PurchasePrice {Amount, CurrencyId},Currency—CurrencySnapshotрезолвленной валюты,PurchasedAt,Version); при фиксации продажи —InvestorPropertySoldKafkaEvent→pin.investor-owner.property-sold.v1(payload:Id,InvestorUserId,PropertyObjectId,SalePrice,SoldAt,RealizedProfit,Currency,Version). - Результат: событие подтверждается (ack).
- Идемпотентность по
- Мониторинг: метрики
owners_sync_lag_seconds(now − maxLastSyncedAt),owner_mappings_conflicts_total; алерт при лаге > 15 мин, DLQ > 0, росте конфликтов > 10/день (README «Runtime И Эксплуатация»).
Альтернативные Потоки
A1. identity недоступен при линковке
- Вызов
provision-investorзавершается ошибкой (таймаут/5xx) → связь остаётся в статусеPendingLink(снапшот иReplicaVersionуже применены), возвращается retriable-отказINVESTOR_OWNER_PROVISION_UNAVAILABLE. - Retry с экспоненциальным backoff → DLQ при исчерпании попыток; алерт «ошибки identity provision-API».
- После восстановления
identityповторная доставка события повторяет линковку — возврат к шагу 2.4 основного потока.
A2. Событие с устаревшей версией (out-of-order)
- Версия события не превышает версию реплики (
Version <= ReplicaVersion) → идемпотентный пропуск (INV-6), метрикаowners_sync_stale_events_total, подтверждение (ack). Состояние не меняется.
A3. Невалидный телефон из Talent Owners
- Номер не нормализуется в E.164 (INV-8) → связь помечается конфликтом
PhoneInvalid(исходное значение маскируется); подтверждение (ack), не DLQ — исправляется только новым событием Talent или вручную. - Разрешение — A5 либо следующее событие собственника с валидным номером (статус
Conflictсохраняется до ручного разрешения; обновление снапшота перезаписываетConflictDetails).
A4. Удаление собственника в Talent Owners
- Talent Owners публикует событие удаления собственника; если связи по
OwnerIdнет → идемпотентное подтверждение. - Связь мягко удаляется (
DeletedAt, без владельца-удалителя) — переходLinked/Conflict → [*]; записи портфеля НЕ удаляются и остаются у инвестора (InvestorUserId) (поведение удаления restrict, «Связи» owner-mapping.md).
A5. Ручное разрешение конфликта (Служба заботы)
- Оператор Службы заботы с permission
investor-owner.mappings.manage(платформенный scope) открывает очередь конфликтов через admin-APIlist-owner-mapping-conflicts(записи со статусомConflict). - Операция
resolve-owner-mapping-conflict(mappingId, investorUserId)переводит связьConflict → Linked, проставляетResolvedByUserId,LinkKind = ExistingInvestorMatched, фиксирует аудит и публикуетInvestorOwnerLinkedKafkaEvent. - Отложенные события владения (INV-8) применяются при следующем retry/replay — объекты появляются в портфеле.
Постусловие
OwnerMappingEntity (Id = OwnerId Talent)существует; приStatus = LinkedзаполненыInvestorUserId,LinkKind,LinkedAt(INV-2); приLinkKind = InvestorCreatedвidentityсуществуетUserEntity(Id = OwnerId, Type = Investor)— INV-OWN-001.- Объекты владения собственника реплицированы в
InvestorPropertyEntity (Source = TalentOwnersSync, OwnerMappingId, ExternalOwnershipId); проданные — со статусомSoldи зафиксированнымRealizedProfit. ReplicaVersion/LastSyncedAtактуальны (мониторинг лага); конфликты видимы в admin-очереди.- Downstream-контексты получили
pin.investor-owner.owner-linked.v1/property-added.v1/property-sold.v1.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
INVESTOR_OWNER_PHONE_ALREADY_MAPPED | — (conflict-запись) | Телефон уже связан с другим собственником (INV-4) | не ошибка обработки: связь помечается конфликтом PhoneAlreadyMappedToAnotherOwner + admin-очередь |
INVESTOR_OWNER_ID_MISMATCH | — (DLQ) | identity вернул Created с UserId != OwnerId (нарушение INV-OWN-001) | DLQ + алерт; разбор runbook-ом (дефект identity) |
INVESTOR_OWNER_MAPPING_NOT_LINKED | — (retriable) | Ownership-событие пришло до установки связи (INV-8) | retry с backoff → DLQ; replay после линковки |
INVESTOR_OWNER_PROVISION_UNAVAILABLE | — (retriable) | identity internal-API недоступен (A1) | связь в PendingLink; retry → DLQ + алерт |
INVESTOR_OWNER_PRICE_INVALID | — (DLQ) | PurchasePrice.Amount <= 0 в ownership-событии (INV-4) | DLQ + алерт; исправление данных на стороне Talent |
INVESTOR_OWNER_CURRENCY_MISMATCH | — (DLQ) | Валюта продажи не равна валюте покупки (INV-5) | DLQ; ручной разбор |
INVESTOR_OWNER_SALE_DATA_INCOMPLETE | — (DLQ) | В ownership-событии с продажей SoldAt < PurchasedAt (INV-3 SoldCompleteness) | DLQ + алерт; исправление данных на стороне Talent |
ValidationError | — (conflict-запись/DLQ) | Невалидный телефон (A3) / пустые обязательные поля события | конфликт PhoneInvalid либо DLQ при битом payload |
HTTP-кодов нет — сценарий полностью событийный; «клиент» — retry/DLQ-политика обработки.
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
OwnerMappingLinked | Переход PendingLink/Conflict → Linked | in-proc (аудит, публикация интеграционного события) |
OwnerMappingConflictDetected | Переход PendingLink → Conflict | in-proc (метрика конфликтов, admin-очередь) |
InvestorPropertyAdded | Создание записи портфеля из синка | in-proc |
InvestorPropertySold | Фиксация продажи из ownership-события | in-proc |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
InvestorOwnerLinkedKafkaEvent.v1 | pin.investor-owner.owner-linked.v1 | Успешный переход в Linked (авто или A5) | profile (создание профиля при InvestorCreated), notifications (приветствие) |
InvestorPropertyAddedKafkaEvent.v1 | pin.investor-owner.property-added.v1 | Создание записи портфеля из синка | notifications, user-activity |
InvestorPropertySoldKafkaEvent.v1 | pin.investor-owner.property-sold.v1 | Фиксация продажи по данным Talent Owners | notifications, user-activity |
Acceptance Criteria
| # | Критерий | Проверка |
|---|---|---|
| AC1 | Повторная доставка owner/ownership-события не меняет состояние (idempotency EventId + INV-6/INV-7) | тест двойной доставки |
| AC2 | Новый собственник без инвестора в PIN → создан UserEntity(Id = OwnerId, Type = Investor); LinkKind = InvestorCreated, InvestorUserId = Id (INV-OWN-001) | интеграционный тест с identity |
| AC3 | Существующий инвестор с тем же телефоном → LinkKind = ExistingInvestorMatched, новый пользователь НЕ создаётся | тест сопоставления |
| AC4 | Второй собственник с занятым телефоном → Status = Conflict (PhoneAlreadyMappedToAnotherOwner), авто-линковка отсутствует | тест конфликта |
| AC5 | Ownership-событие до линковки → retry, после Linked объект появляется в портфеле (INV-8) | тест порядка событий |
| AC6 | Уже проданный объект из Talent → запись сразу Sold c вычисленным RealizedProfit = SalePrice − PurchasePrice | тест создания в Sold |
| AC7 | identity недоступен → связь остаётся PendingLink, после восстановления replay линкует (A1) | тест отказа identity |
| AC8 | pin.investor-owner.owner-linked.v1 не содержит PhoneNumber (PII) | контракт-тест payload |
| AC9 | Ручное разрешение конфликта переводит Conflict → Linked c ResolvedByUserId и публикует owner-linked (A5) | тест admin-операции |
| AC10 | Лаг синка виден метрикой owners_sync_lag_seconds; алерт при > 15 мин | проверка метрик |
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (3): UC-OWN-002. Завершение сделки в CRM → купленный объект в «Мои объекты недвижимости» (system), UC-OWN-003. Инвестор просматривает портфель «Мои объекты недвижимости» и доходность, UC-OWN-004. Карточка объекта портфеля с историей изменения цен
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- Consumer детально: ../consumers/talent-owners-sync.md (будущий путь).
- Domain entity: ../domain/owner-mapping.md (INV-OWN-001, INV-2..8, state machine), ../domain/investor-property.md (INV-1..9, создание из синка, фиксация продажи).
- Internal-API identity: ../../identity/api/provision-investor.md (будущий путь); учётка — ../../identity/domain/user.md.
- Admin-API разрешения конфликтов: ../api/admin/resolve-owner-mapping-conflict.md (будущий путь).
- Связанные UC: UC-OWN-002 (объект из сделки CRM), UC-OWN-003 (просмотр портфеля).
- Решения: ADR-0052 (инвестор вне tenant), ADR-0056 (simplicity-first),
ТехЗадание.md.