UC-OWN-002. Завершение сделки в CRM → купленный объект в «Мои объекты недвижимости» (system)
Назначение
Описывает системный сценарий появления купленного объекта в портфеле инвестора: контекст crm завершает
сделку покупки и публикует событие pin.crm.deal-completed.v1, а сервис investor-owner создаёт запись
портфеля InvestorPropertyEntity (Source = PinCrmDeal, Status = Active) — купленный объект появляется
у инвестора в разделе «Мои объекты недвижимости» (/profile/my-lots). Запускается автоматически при
завершении сделки. Связан с контекстом investor-owner.
| Поле | Значение |
|---|
| ID | UC-OWN-002 |
| Контекст | investor-owner (см. docs/06-services/investor-owner/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|
| Роль | System (системный сценарий сервиса investor-owner; system-actor) |
| Permissions / scope | permissions не применяются: доступ к топику pin.crm.deal-completed.v1 и вызов internal-API chessboard get-property-objects-by-ids (дозаполнение снапшота) — межсервисные (m2m); мутации — под system-actor (CreatedByUserId = null) |
| Tenant | не применяется: запись портфеля — вне tenant (ADR-0052); TenantId сделки из payload не переносится |
| Record-level | не применяется на записи; InvestorUserId берётся из payload сделки |
Предусловие
- Событийная шина доступна; сервис подписан на топик
pin.crm.deal-completed.v1.
- Событие несёт снапшот объекта и итоговую цену сделки (требование к контракту
crm, будущий путь
../../crm/events/deal-completed.md): DealId, InvestorUserId,
PropertyObjectId, ProjectId, BuildingId, DeveloperId, FinalPrice {Amount, CurrencyId},
Currency (CurrencySnapshot валюты —
../../handbook/domain/snapshots.md),
CompletedAt, PropertyObjectSnapshot {Title, Number, Project {ProjectId, ProjectName}, Building {BuildingId, BuildingName}, AreaTotal, Floor},
Version.
- Хранилище контекста
investor-owner развёрнуто (в т.ч. уникальность по DealId —
ux_investor_properties_deal_id — и по активной паре инвестор+объект —
ux_investor_properties_investor_property_object_active).
InvestorUserId в событии — существующий пользователь-инвестор PIN; investor-owner наличие учётки в
identity не проверяет (ссылка без FK, чужой контекст) — это гарантия контракта crm.
Триггер
| Поле | Значение |
|---|
| Источник | Kafka event |
| Триггерящий элемент | Событие завершения сделки DealCompletedKafkaEvent (pin.crm.deal-completed.v1); обработка — ../consumers/crm-deal-completed.md (будущий путь) |
Основной Поток
- Менеджер/агент завершает сделку покупки в CRM;
crm фиксирует переход Deal → Completed и публикует
событие DealCompletedKafkaEvent в топик pin.crm.deal-completed.v1.
- Сервис
investor-owner принимает событие завершения сделки:
- Идемпотентность: повторная доставка того же события (по
EventId) подтверждается без эффекта
(сквозной trace от сделки сохраняется); обработка под system-actor.
- Проверка входа и предусловий:
DealId, InvestorUserId, PropertyObjectId заданы;
FinalPrice.Amount > 0 (INV-4 PositivePrices), FinalPrice.CurrencyId задан (типизированная
ссылка на справочник валют handbook; синхронный резолв не выполняется — валюту валидировал crm
на своём write-path, событие несёт готовый CurrencySnapshot);
CompletedAt <= now() (ограничение поля PurchasedAt);
PropertyObjectSnapshot.Title непустой — при пустом снапшоте см. A3;
- бизнес-идемпотентность (INV-6 DealIdempotency): если запись портфеля с таким
DealId уже есть →
идемпотентный no-op, ack (A1);
- единственность активной записи (INV-1 SingleActivePerLot): если у инвестора уже есть активная
запись на этот объект (
Status = Active) → A4 (DLQ + разбор Службой заботы), автоматических
мутаций нет.
- Дополнительная загрузка не требуется — запись портфеля самодостаточна; связь собственника не
читается (для
Source = PinCrmDeal OwnerMappingId = null, INV-2 SourceConsistency).
- Создаём запись портфеля из данных сделки (единственный путь создания для этого источника, INV-2) с
маппингом поле-в-поле:
Id — новый идентификатор;
InvestorUserId ← payload.InvestorUserId (покупатель сделки);
DealId ← payload.DealId; OwnerMappingId = null; ExternalOwnershipId = null;
PropertyObjectId ← payload.PropertyObjectId; ProjectId ← payload.ProjectId;
BuildingId ← payload.BuildingId; DeveloperId ← payload.DeveloperId;
PropertyObjectSnapshot ← payload.PropertyObjectSnapshot (SnapshotAt — время события) —
иммутабельная фиксация «что купили»; недостающие необязательные поля — A3;
PurchasePrice ← payload.FinalPrice (Amount + CurrencyId — итоговая цена сделки, «за сколько
купили»); денормализованный CurrencyIsoCode ← payload.Currency.IsoCode (display-код, снятый при
записи);
PurchasedAt ← payload.CompletedAt (дата завершения сделки);
Source = PinCrmDeal; Status = Active (переход [*] → Active);
SalePrice/SoldAt/RealizedProfit = null (объект только куплен);
- фиксируется доменный факт
InvestorPropertyAdded.
- Согласованность: запись портфеля создаётся, аудит/версионирование (
CreatedAt, Version = 1) и факт
property-added фиксируются в одном согласованном изменении; уникальный индекс
ux_investor_properties_deal_id — последний рубеж гонки двух параллельных обработчиков (проигравший
подтверждает существование записи по DealId и идемпотентно завершается).
- События: публикуется
InvestorPropertyAddedKafkaEvent (топик pin.investor-owner.property-added.v1,
ключ и AggregateId = Id; payload: Id, InvestorUserId, PropertyObjectId, ProjectId,
Source = PinCrmDeal, PurchasePrice {Amount, CurrencyId}, Currency (CurrencySnapshot из
входного события), PurchasedAt, Version).
- Результат: событие подтверждается (ack). Появление объекта в портфеле — событийное: до обработки
события инвестор объект в «Мои объекты недвижимости» ещё не видит (окно eventual-consistency, целевой
SLA появления < 1 мин; лаг обработки мониторится метрикой/алертом, README «Runtime И Эксплуатация»).
notifications (потребитель pin.investor-owner.property-added.v1) отправляет инвестору уведомление
«объект появился в портфеле»; user-activity пишет запись в ленту /profile/events.
- Инвестор открывает «Мои объекты недвижимости» и видит купленный объект
(UC-OWN-003).
Альтернативные Потоки
A1. Повторная доставка / replay события
- Запись портфеля с таким
DealId уже существует → идемпотентный no-op (INV-6), метрика
deal_completed_duplicates_total, ack. Дубль в портфеле не появляется.
A2. Невалидный payload (цена/даты)
FinalPrice.Amount <= 0 (INV-4) либо CompletedAt в будущем → ошибка INVESTOR_OWNER_PRICE_INVALID /
ValidationError; событие в DLQ + алерт (ошибка контракта crm, retry бессмыслен).
- Исправление — на стороне
crm (компенсирующее событие) либо ручное создание записи через admin-API
create-investor-property Службой заботы (permission investor-owner.portfolio.manage, с аудитом).
A3. Снапшот в payload неполон
- Обязательный
PropertyObjectSnapshot.Title есть, необязательные поля (AreaTotal, Floor, Project,
Building) отсутствуют → выполняется вызов internal-API chessboard
getPropertyObject(PropertyObjectId) (../../chessboard/api/internal/property-objects/get-property-objects-by-ids.md,
будущий путь), и снапшот дозаполняется перед созданием записи.
chessboard недоступен → запись создаётся из имеющегося payload (портфель не блокируется); недостающие
поля дозаполняются отложенным retry-джобом (README «Зависимости»). Возврат к шагу 2.4.
- Отсутствует сам
PropertyObjectSnapshot.Title → DLQ (ValidationError) — контракт crm обязан нести
снапшот (README «Известные Ограничения»).
A4. У инвестора уже есть Active-запись на этот объект недвижимости
- Найдена активная запись на пару (инвестор, объект) (INV-1) — данные рассинхронизированы (например, дубль
владения из синка Talent Owners и сделка PIN на один объект недвижимости).
- Событие → DLQ с кодом
INVESTOR_OWNER_PROPERTY_ALREADY_ACTIVE + тикет Службы заботы; авто-merge
запрещён (финансовые данные).
- Разрешение: забота архивирует ошибочную запись (
archive-investor-property, soft-delete + аудит) и
повторяет событие из DLQ → основной поток с шага 2.
A5. Гонка двух обработчиков
- Оба не нашли запись по
DealId; вторая вставка нарушает уникальность ux_investor_properties_deal_id.
- Проигравший обработчик подтверждает существование записи (по
DealId) → идемпотентный ack (запись и
событие уже созданы первым).
Постусловие
- В
investor_owner.investor_properties существует ровно одна запись с DealId = payload.DealId:
Source = PinCrmDeal, Status = Active, PurchasePrice = FinalPrice, PurchasedAt = CompletedAt,
OwnerMappingId = null, заполненный PropertyObjectSnapshot (INV-2, INV-4, INV-6).
- Событие
pin.investor-owner.property-added.v1 опубликовано в том же согласованном изменении.
- Инвестор видит объект в «Мои объекты недвижимости»;
notifications/user-activity получили событие.
- Повторные доставки и replay не создают дублей.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|
INVESTOR_OWNER_PRICE_INVALID | — (DLQ) | FinalPrice.Amount <= 0 (INV-4, A2) | DLQ + алерт; исправление контракта/данных crm |
INVESTOR_OWNER_PROPERTY_ALREADY_ACTIVE | — (DLQ) | Активная запись на пару (инвестор, объект недвижимости) уже есть (INV-1, A4) | DLQ + тикет заботы; replay после разбора |
INVESTOR_OWNER_PROPERTY_SOURCE_MISMATCH | — (DLQ) | Нарушение согласованности источника при создании записи (INV-2; дефект кода) | DLQ + алерт разработке |
ValidationError | — (DLQ) | Пустые обязательные поля payload (DealId/InvestorUserId/PropertyObjectId/PropertyObjectSnapshot.Title), дата в будущем | DLQ + алерт; контрактная ошибка crm |
HTTP-кодов нет — сценарий полностью событийный; «клиент» — retry/DLQ-политика обработки.
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|
InvestorPropertyAdded | Создание записи портфеля из сделки | in-proc (публикация интеграционного события, аудит) |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|
InvestorPropertyAddedKafkaEvent.v1 | pin.investor-owner.property-added.v1 | Создание записи портфеля из сделки | notifications («объект появился в портфеле»), user-activity (лента /profile/events) |
Consumed: DealCompletedKafkaEvent.v1 ← pin.crm.deal-completed.v1 (контракт —
../../crm/events/deal-completed.md, будущий путь).
Acceptance Criteria
| # | Критерий | Проверка |
|---|
| AC1 | После deal-completed запись портфеля появляется со всеми полями покупки (PurchasePrice = FinalPrice, PurchasedAt = CompletedAt, Source = PinCrmDeal) | интеграционный тест обработки |
| AC2 | Повторная доставка того же события не создаёт дубль (INV-6 + idempotency EventId) | тест двойной доставки |
| AC3 | Гонка двух обработчиков гасится unique ux_investor_properties_deal_id (A5) | конкурентный тест |
| AC4 | Неполный снапшот дозаполняется из chessboard; при его недоступности запись создаётся из payload (A3) | тест с выключенным chessboard |
| AC5 | Невалидная цена → DLQ, портфель не мутирует (A2) | тест DLQ |
| AC6 | Активный дубль по объекту недвижимости → DLQ INVESTOR_OWNER_PROPERTY_ALREADY_ACTIVE, авто-merge отсутствует (A4) | тест конфликта INV-1 |
| AC7 | property-added публикуется в том же согласованном изменении, что и создание записи | тест согласованной публикации |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы