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

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

ПолеЗначение
IDUC-OWN-001
Контекстinvestor-owner (см. docs/06-services/investor-owner/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольSystem (системный сценарий синхронизации, system-actor; не пользовательский контекст)
Permissions / scopepermissions не применяются: доступ к топикам 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 identity provision-investor доступен; при его недоступности связь остаётся в статусе PendingLink, событие повторяется (A1).

Триггер

ПолеЗначение
ИсточникKafka event
Триггерящий элементСобытие собственника (OwnerUpsertedEvent/OwnerDeletedEvent) или владения (OwnershipUpsertedEvent) из Talent Owners; обработка — ../consumers/talent-owners-sync.md (будущий путь)

Основной Поток

  1. Talent Owners публикует событие собственника OwnerUpsertedEvent (снапшот: OwnerId, PhoneNumber, FirstName, LastName, MiddleName, Version) в топик talent.owners.owners.v1.
  2. Сервис investor-owner принимает событие собственника:
    1. Идемпотентность: повторная доставка того же события (по EventId) подтверждается без эффекта; обработка идёт под system-actor.
    2. Проверка входа: OwnerId задан, PhoneNumber непустой, FirstName не короче 1 символа; номер нормализуется в формат E.164 — если нормализация невозможна, связь помечается конфликтом PhoneInvalid (INV-8, A3). Version-guard (INV-6): событие применяется только если его Version больше версии реплики (ReplicaVersion); иначе — идемпотентный пропуск и метрика owners_sync_stale_events_total.
    3. Ищем существующую связь собственника по OwnerId (её может ещё не быть — первое событие) и проверяем, не занят ли нормализованный телефон другой активной связью (INV-4 UniquePhone).
    4. Применяем правила и маппинг:
      • связи нет → создаём связь: телефон — нормализованный, OwnerFullName из FirstName/LastName/ MiddleName, статус PendingLink, ReplicaVersion из события, LastSyncedAt — время события;
      • связь есть → обновляем снапшот из источника: перезаписываются только синк-владеемые поля PhoneNumber, OwnerFullName, ReplicaVersion, LastSyncedAt; локальные поля связи (InvestorUserId, LinkKind, Status) не трогаются (INV-7);
      • нормализованный телефон уже связан с другим собственником → связь помечается конфликтом PhoneAlreadyMappedToAnotherOwner (INV-3, INV-4), линковка не выполняется;
      • иначе, если статус PendingLink — линковка: межсервисный вызов identity provision-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;
        • IdCollisionidentity уже есть пользователь Id = OwnerId с другим телефоном) → конфликт IdentityIdCollision;
      • при переходе в Linked фиксируется доменный факт OwnerMappingLinked, при любом из конфликтов — OwnerMappingConflictDetected.
    5. Согласованность: изменение связи, аудит/версионирование и факт линковки/конфликта фиксируются в одном согласованном изменении. Уникальные индексы ux_owner_mappings_phone_number / ux_owner_mappings_investor_user_id — последний рубеж гонки двух параллельных обработчиков (проигравший ловит конфликт вставки и уходит в конфликтную запись).
    6. События: при переходе в Linked публикуется интеграционное событие InvestorOwnerLinkedKafkaEvent (топик pin.investor-owner.owner-linked.v1, ключ и AggregateId = Id; payload: OwnerId (= Id), InvestorUserId, LinkKind, LinkedAt, Version; телефон в payload НЕ передаётся — PII).
    7. Результат: событие подтверждается (ack).
  3. Talent Owners публикует событие владения OwnershipUpsertedEvent (снапшот: OwnershipId, OwnerId, атрибуты объекта недвижимости, PurchasePrice, PurchasedAt, опционально SalePrice/SoldAt, Version) в топик talent.owners.ownerships.v1.
  4. Сервис investor-owner принимает событие владения:
    1. Идемпотентность по EventId.
    2. Проверка: связь собственника по 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).
    3. Ищем запись портфеля по внешнему идентификатору владения ExternalOwnershipId (может ещё не быть — идемпотентность синка объектов, INV-7 investor-property).
    4. Применяем правила и маппинг:
      • записи нет → создаём запись портфеля из данных синка: 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.
    5. Согласованность: запись портфеля и факт её создания/продажи фиксируются в одном согласованном изменении; уникальные индексы ux_investor_properties_external_ownership_id и ux_investor_properties_investor_property_object_active (INV-1) гасят гонки.
    6. События: при создании — InvestorPropertyAddedKafkaEventpin.investor-owner.property-added.v1 (payload: Id, InvestorUserId, PropertyObjectId, ProjectId, Source, PurchasePrice {Amount, CurrencyId}, CurrencyCurrencySnapshot резолвленной валюты, PurchasedAt, Version); при фиксации продажи — InvestorPropertySoldKafkaEventpin.investor-owner.property-sold.v1 (payload: Id, InvestorUserId, PropertyObjectId, SalePrice, SoldAt, RealizedProfit, Currency, Version).
    7. Результат: событие подтверждается (ack).
  5. Мониторинг: метрики owners_sync_lag_seconds (now − max LastSyncedAt), owner_mappings_conflicts_total; алерт при лаге > 15 мин, DLQ > 0, росте конфликтов > 10/день (README «Runtime И Эксплуатация»).

Альтернативные Потоки

A1. identity недоступен при линковке

  1. Вызов provision-investor завершается ошибкой (таймаут/5xx) → связь остаётся в статусе PendingLink (снапшот и ReplicaVersion уже применены), возвращается retriable-отказ INVESTOR_OWNER_PROVISION_UNAVAILABLE.
  2. Retry с экспоненциальным backoff → DLQ при исчерпании попыток; алерт «ошибки identity provision-API».
  3. После восстановления identity повторная доставка события повторяет линковку — возврат к шагу 2.4 основного потока.

A2. Событие с устаревшей версией (out-of-order)

  1. Версия события не превышает версию реплики (Version <= ReplicaVersion) → идемпотентный пропуск (INV-6), метрика owners_sync_stale_events_total, подтверждение (ack). Состояние не меняется.

A3. Невалидный телефон из Talent Owners

  1. Номер не нормализуется в E.164 (INV-8) → связь помечается конфликтом PhoneInvalid (исходное значение маскируется); подтверждение (ack), не DLQ — исправляется только новым событием Talent или вручную.
  2. Разрешение — A5 либо следующее событие собственника с валидным номером (статус Conflict сохраняется до ручного разрешения; обновление снапшота перезаписывает ConflictDetails).

A4. Удаление собственника в Talent Owners

  1. Talent Owners публикует событие удаления собственника; если связи по OwnerId нет → идемпотентное подтверждение.
  2. Связь мягко удаляется (DeletedAt, без владельца-удалителя) — переход Linked/Conflict → [*]; записи портфеля НЕ удаляются и остаются у инвестора (InvestorUserId) (поведение удаления restrict, «Связи» owner-mapping.md).

A5. Ручное разрешение конфликта (Служба заботы)

  1. Оператор Службы заботы с permission investor-owner.mappings.manage (платформенный scope) открывает очередь конфликтов через admin-API list-owner-mapping-conflicts (записи со статусом Conflict).
  2. Операция resolve-owner-mapping-conflict(mappingId, investorUserId) переводит связь Conflict → Linked, проставляет ResolvedByUserId, LinkKind = ExistingInvestorMatched, фиксирует аудит и публикует InvestorOwnerLinkedKafkaEvent.
  3. Отложенные события владения (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 → Linkedin-proc (аудит, публикация интеграционного события)
OwnerMappingConflictDetectedПереход PendingLink → Conflictin-proc (метрика конфликтов, admin-очередь)
InvestorPropertyAddedСоздание записи портфеля из синкаin-proc
InvestorPropertySoldФиксация продажи из ownership-событияin-proc

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
InvestorOwnerLinkedKafkaEvent.v1pin.investor-owner.owner-linked.v1Успешный переход в Linked (авто или A5)profile (создание профиля при InvestorCreated), notifications (приветствие)
InvestorPropertyAddedKafkaEvent.v1pin.investor-owner.property-added.v1Создание записи портфеля из синкаnotifications, user-activity
InvestorPropertySoldKafkaEvent.v1pin.investor-owner.property-sold.v1Фиксация продажи по данным Talent Ownersnotifications, 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), авто-линковка отсутствуеттест конфликта
AC5Ownership-событие до линковки → retry, после Linked объект появляется в портфеле (INV-8)тест порядка событий
AC6Уже проданный объект из Talent → запись сразу Sold c вычисленным RealizedProfit = SalePrice − PurchasePriceтест создания в Sold
AC7identity недоступен → связь остаётся PendingLink, после восстановления replay линкует (A1)тест отказа identity
AC8pin.investor-owner.owner-linked.v1 не содержит PhoneNumber (PII)контракт-тест payload
AC9Ручное разрешение конфликта переводит Conflict → Linked c ResolvedByUserId и публикует owner-linked (A5)тест admin-операции
AC10Лаг синка виден метрикой owners_sync_lag_seconds; алерт при > 15 минпроверка метрик

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

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

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