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

Entity / Aggregate: InvestorPropertyEntity

Дом полей агрегата «Объект инвестора» (портфель, страница /profile/my-lots — «Мои объекты недвижимости») контекста investor-owner. Все проекции (модели ответов API портфеля, payload событий pin.investor-owner.property-*.v1, сводка доходности) ссылаются сюда и описывают только дельту.

Назначение

InvestorPropertyEntity — купленный объект недвижимости в портфеле инвестора:

  • за сколько купили (PurchasePrice, PurchasedAt) и за сколько продали (SalePrice, SoldAt);
  • сколько и когда заработали — реализованный доход RealizedProfit фиксируется в момент продажи (EarnedAt = SoldAt); нереализованный доход (текущая оценка − цена покупки) — read-проекция от текущей цены объекта недвижимости и не хранится здесь;
  • история изменения цен объекта — не хранится в этом контексте: карточка объекта ссылается на invest-analytics (../../invest-analytics/api/public/get-property-object-price-history.md) по PropertyObjectId.

Два источника появления объекта в портфеле (Source):

  1. PinCrmDeal — после завершения сделки в CRM (consumer pin.crm.deal-completed.v1, ../consumers/crm-deal-completed.md) купленный объект появляется у инвестора;
  2. TalentOwnersSync — объекты собственника из Talent Owners, привязанные через owner-mapping.md (OwnerMappingId).

Снапшот-поля объекта недвижимости (PropertyObjectSnapshot) денормализуются для отображения списка «Мои объекты недвижимости» без запросов в chessboard; source of truth по структуре объекта недвижимости — chessboard (../../chessboard/domain/property-object.md).

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

ПолеЗначение
Контекстinvestor-owner
KindAggregateRoot
Source of truthвладение, цены покупки/продажи, доход — investor-owner; структура/атрибуты объекта недвижимости — chessboard; история цен — invest-analytics; факт завершения сделки — crm
Таблицаinvestor_owner.investor_properties
Tenant isolationне tenanted: данные инвестора — вне tenant (ADR-0052); изоляция — record-level по InvestorUserId

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор записи портфеля.
InvestorUserIdGuidДаindex ix_investor_properties_investor_user_id; ссылка на identity.users(id) без FK (чужой контекст)consumer (deal-completed: InvestorUserId сделки; owners-sync: OwnerMapping.InvestorUserId)Владелец записи; record-level ключ изоляции.
OwnerMappingIdGuid?Нет (обязателен при Source = TalentOwnersSync)FK fk_investor_properties_owner_mapping_id → investor_owner.owner_mappings(id), index ix_investor_properties_owner_mapping_idconsumer owners-syncСвязь с собственником Talent Owners. null для объектов из сделок PIN (INV-2).
StatusOwnershipStatusДахранится как text (varchar(32)), index ix_investor_properties_statusappActive / Sold (enum ниже).
SourcePropertySourceДахранится как text (varchar(32))consumerИсточник появления записи (enum ниже). Иммутабельно.
DealIdGuid?Нет (обязателен при Source = PinCrmDeal)unique ux_investor_properties_deal_id where deal_id is not null; ссылка на сделку crm без FKсобытие pin.crm.deal-completed.v1Идемпотентность consumer-а CRM: повторное событие по той же сделке не создаёт дубль (INV-6).
ExternalOwnershipIdGuid?Нет (обязателен при Source = TalentOwnersSync)unique ux_investor_properties_external_ownership_id where external_ownership_id is not nullсобытие синхронизации Talent OwnersId записи владения во внешней системе; идемпотентность синхронизации объектов.
PropertyObjectIdGuidДаindex ix_investor_properties_property_object_id; ссылка на объект недвижимости chessboard без FKconsumerОбъект недвижимости; ключ для истории цен invest-analytics и карточки объекта недвижимости.
ProjectIdGuid?Нетindex ix_investor_properties_project_idconsumer / chessboard APIПроект (ЖК/отель) — денормализация для группировки портфеля. null, если объект Talent Owners не сопоставлен с шахматкой PIN.
BuildingIdGuid?Нетconsumer / chessboard APIЗдание/литер — денормализация для карточки.
DeveloperIdGuid?Нетconsumer / chessboard APIЗастройщик — денормализация для карточки.
PropertyObjectSnapshotPropertyObjectSnapshot (VO)Дасм. VO нижеpayload события + chessboard APIСнапшот объекта недвижимости для отображения без cross-context запросов.
PurchasePriceMoney (VO)ДаAmount > 0 (INV-4)deal-completed (итоговая цена сделки) / Talent OwnersЗа сколько купили.
PurchasedAtDateTimeOffsetДаindex ix_investor_properties_purchased_at; PurchasedAt <= now()deal-completed (CompletedAt) / Talent OwnersДата покупки (завершение сделки).
SalePriceMoney? (VO)Нет (обязателен при Status = Sold)Amount > 0; CurrencyId = PurchasePrice.CurrencyId (INV-5)синхронизация Talent Owners / admin-API / будущая сделка продажи CRMЗа сколько продали.
SoldAtDateTimeOffset?Нет (обязателен при Status = Sold)SoldAt >= PurchasedAt (INV-3)тот же источник, что SalePriceДата продажи; она же «когда заработали» (EarnedAt).
RealizedProfitMoney? (VO)Нет (обязателен при Status = Sold)вычисляется агрегатом, не принимается извневычисление: SalePrice.Amount − PurchasePrice.Amount (валюта — общая)Сколько заработали (может быть отрицательным — убыток). Фиксируется при продаже атомарно с SalePrice/SoldAt.
CurrencyIsoCodestringДа3 символа, ISO-4217; column currency_iso_codeconsumer / admin-API: CurrencySnapshot.IsoCode входного события либо резолв справочника валют handbookДенормализованный display-код единой валюты записи (INV-5), снятый при записи (правило снапшотов handbook). Только для отображения (сводка портфеля, сверка с кодом валюты оценки invest-analytics); в бизнес-логике не участвует.
CreatedAtDateTimeOffsetДаindex ix_investor_properties_created_atappПоявление записи в портфеле.
CreatedByUserIdGuid?Нетappnull — создано consumer-ом; иначе id актора admin-API.
UpdatedAtDateTimeOffsetДаappМомент последнего изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?НетappSoft-delete (ручной архив ошибочной записи).
DeletedByUserIdGuid?НетappКто архивировал.
VersionlongДа>= 1appДоменная версия записи (растёт при каждом изменении; входит в payload событий).

Enum OwnershipStatus

ЗначениеОписание
ActiveОбъект в собственности инвестора: показывается в «Мои объекты недвижимости» c текущей оценкой (read-проекция от invest-analytics/catalog).
SoldОбъект продан: зафиксированы SalePrice, SoldAt, RealizedProfit; остаётся в истории портфеля («сколько и когда заработали»).

Enum PropertySource

ЗначениеОписание
PinCrmDealСоздан consumer-ом pin.crm.deal-completed.v1 после завершения сделки в CRM PIN.
TalentOwnersSyncСоздан consumer-ом синхронизации объектов Talent Owners через связь owner-mapping.md.
AdminCorrectionКомпенсирующее ручное создание Службой заботы (admin-операция ../api/admin/create-investor-property.md); внешних ключей идемпотентности нет (DealId/ExternalOwnershipId/OwnerMappingId = null, INV-2 relaxed).

Value objects

Money

ПолеТипОбязательностьОграниченияКолонка
AmountdecimalДаnumeric(18,2); для RealizedProfit допускается < 0, для цен — > 0purchase_price_amount / sale_price_amount / realized_profit_amount
CurrencyIdshortДассылка на справочник валют handbook (../../handbook/domain/currency.md) без FK (чужой контекст); default — валюта RUBpurchase_price_currency_id / sale_price_currency_id / realized_profit_currency_id

Типизированная ссылка CurrencyId — не мультивалютность: платформа остаётся RUB-only (ADR-0048). Display-код валюты в контрактах живёт внутри CurrencySnapshot (../../handbook/domain/snapshots.md) — payload событий агрегата несут CurrencyId + CurrencySnapshot; для чтения без резолва запись хранит денормализованный CurrencyIsoCode (поле агрегата выше).

PropertyObjectSnapshot (дельта от дома полей объекта недвижимости ../../chessboard/domain/property-object.md)

ПолеТипОбязательностьОграниченияКолонка
TitlestringДаvarchar(256), min 1property_object_snapshot_title
Numberstring?Нетvarchar(64)property_object_snapshot_number
ProjectProjectRefModel?Нетref-модель ниже; null — объект недвижимости не сопоставлен с шахматкой PINproperty_object_snapshot_project_*
BuildingBuildingRefModel?Нетref-модель ниже; null при отсутствии сопоставленияproperty_object_snapshot_building_*
AreaTotaldecimal?Нетnumeric(10,2), > 0property_object_snapshot_area_total
Floorint?Нет>= -5, <= 200property_object_snapshot_floor
SnapshotAtDateTimeOffsetДаproperty_object_snapshot_snapshot_at

Пары id + name проекта и корпуса свёрнуты во вложенные ref value objects (strict-typed strict-typed-documentation-rules.md, а не плоские *Name); Project.ProjectId/Building.BuildingId — иммутабельный снапшот денормализованных ProjectId/BuildingId записи на момент покупки.

ProjectRefModel (проект — ЖК/отель)

ПолеТипОбязательностьОграниченияКолонка
ProjectIdGuidДаproperty_object_snapshot_project_id
ProjectNamestringДаvarchar(256)property_object_snapshot_project_name

BuildingRefModel (корпус — здание/литер)

ПолеТипОбязательностьОграниченияКолонка
BuildingIdGuidДаproperty_object_snapshot_building_id
BuildingNamestringДаvarchar(256)property_object_snapshot_building_name

Снапшот заполняется в consumer-е из payload события (deal-completed несёт снапшот объекта сделки), недостающие поля — internal-API chessboard getPropertyObject(propertyObjectId) (../../chessboard/api/internal/property-objects/get-property-objects-by-ids.md). Снапшот не обновляется автоматически (иммутабельная фиксация «что купили»); актуальные данные объекта недвижимости фронт берёт из catalog/chessboard по PropertyObjectId.

Модели JSONB

JSONB-модели не используются: снапшот объекта недвижимости — типизированный VO (выше), суммы — VO Money.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
OwnerMappingEntityreferenceOwnerMappingId (FK)consumer синхронизации: проверка Status = Linked до вставкиrestrict
UserEntity (identity)reference (чужой контекст, без FK)InvestorUserIdне загружается; фильтр record-level по актору
Объект недвижимости (chessboard)reference (чужой контекст, без FK)PropertyObjectIdкарточка: catalog/chessboard API по PropertyObjectId; здесь — только снапшот
Сделка (crm)reference (чужой контекст, без FK)DealIdне загружается; ключ идемпотентности consumer-а
История цен (invest-analytics)внешняя проекцияPropertyObjectIdссылка из карточки портфеля на API истории цен

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

ИнвариантУсловиеКогда проверяетсяОшибка
INV-1 SingleActivePerLotУ инвестора не более одной Active записи на объект недвижимости: unique (InvestorUserId, PropertyObjectId) where status = 'Active' and deleted_at is nullunique ux_investor_properties_investor_property_object_active + проверка в consumer-ах до вставкиINVESTOR_OWNER_PROPERTY_ALREADY_ACTIVE
INV-2 SourceConsistencySource = PinCrmDeal ⇔ DealId != null ∧ OwnerMappingId = null; Source = TalentOwnersSync ⇔ ExternalOwnershipId != null ∧ OwnerMappingId != null; Source = AdminCorrection ⇔ DealId = null ∧ ExternalOwnershipId = null ∧ OwnerMappingId = nullпри создании записи (источник задаётся доменом)INVESTOR_OWNER_PROPERTY_SOURCE_MISMATCH
INV-3 SoldCompletenessStatus = Sold ⇒ SalePrice != null ∧ SoldAt != null ∧ RealizedProfit != null ∧ SoldAt >= PurchasedAtпри переводе записи в Sold (единственный путь в статус)INVESTOR_OWNER_SALE_DATA_INCOMPLETE
INV-4 PositivePricesPurchasePrice.Amount > 0; SalePrice.Amount > 0 (при наличии)при создании и продаже; валидация входа consumer-овINVESTOR_OWNER_PRICE_INVALID
INV-5 SingleCurrencySalePrice.CurrencyId = PurchasePrice.CurrencyId; RealizedProfit.CurrencyId — тот же; профит вычисляется агрегатом: RealizedProfit.Amount = SalePrice.Amount − PurchasePrice.Amountпри продажеINVESTOR_OWNER_CURRENCY_MISMATCH
INV-6 DealIdempotencyПовторное deal-completed с тем же DealId не создаёт дубльunique ux_investor_properties_deal_id + проверка по DealId в consumer-е (идемпотентный no-op)идемпотентный пропуск, не ошибка
INV-7 SyncIdempotencyПовторное событие владения с тем же ExternalOwnershipId — upsert, не дубльunique ux_investor_properties_external_ownership_id + проверка по ExternalOwnershipIdидемпотентный upsert
INV-8 LinkedOwnerOnlyОбъект из синхронизации создаётся только при OwnerMapping.Status = Linked (иначе событие ждёт в retry до установки связи)consumer: проверка статуса связи OwnerMappingIdINVESTOR_OWNER_MAPPING_NOT_LINKED (retryable)
INV-9 ImmutablePurchasePurchasePrice, PurchasedAt, Source, DealId, ExternalOwnershipId, PropertyObjectId после создания не меняются; исправление ошибок — admin-операция заботы (архив + новая запись, с аудитом)гарантируется доменом (изменение запрещено)INVESTOR_OWNER_PURCHASE_IMMUTABLE

Примечание к INV-1 (повторная покупка). Единственность распространяется только на Active-записи (частичный unique-индекс where status = 'Active' and deleted_at is null). Проданная (Sold) запись остаётся в истории и не блокирует новую Active по тому же объекту недвижимости: повторная покупка ранее проданного объекта создаёт новую запись, старая Sold сохраняется («сколько и когда заработали»). Несколько Sold-записей по одному объекту за разные периоды владения допустимы (история). Записи разных инвесторов по одному объекту недвижимости друг друга не ограничивают (record-level по InvestorUserId).

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

СобытиеТипКогдаSnapshot/поля
InvestorPropertyAddedдоменноеСоздание записи (сделка / синхронизация / admin)Id, InvestorUserId, PropertyObjectId, Source
InvestorPropertySoldдоменноеActive → Sold (продажа)Id, InvestorUserId, SalePrice, RealizedProfit, SoldAt
InvestorPropertyArchivedдоменноеSoft-delete (admin-архив)Id, InvestorUserId, PropertyObjectId, Reason, DeletedByUserId
InvestorPropertyAddedKafkaEventинтеграционное (ContractVersion = 1, topic pin.investor-owner.property-added.v1, key = Id, AggregateType = InvestorProperty)Создание записи портфеляId, InvestorUserId, PropertyObjectId, ProjectId, Source, PurchasePrice {Amount, CurrencyId}, Currency (CurrencySnapshot), PurchasedAt, Version
InvestorPropertySoldKafkaEventинтеграционное (ContractVersion = 1, topic pin.investor-owner.property-sold.v1, key = Id)Active → SoldId, InvestorUserId, PropertyObjectId, SalePrice {Amount, CurrencyId}, SoldAt, RealizedProfit {Amount, CurrencyId}, Currency (CurrencySnapshot), Version

Потребители: notifications (уведомление «объект появился в портфеле» / «продажа зафиксирована»), user-activity (лента событий пользователя /profile/events). Интеграционные события публикуются атомарно со сменой состояния агрегата. Контракты — ../events/property-added.md, ../events/property-sold.md.

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

АспектЗначение
ИндексыPK (Id); ix_investor_properties_investor_user_id; ux_investor_properties_investor_property_object_active unique (investor_user_id, property_object_id) where status = 'Active' and deleted_at is null; ux_investor_properties_deal_id unique where not null; ux_investor_properties_external_ownership_id unique where not null; ix_investor_properties_owner_mapping_id; ix_investor_properties_property_object_id; ix_investor_properties_project_id; ix_investor_properties_status; ix_investor_properties_purchased_at; ix_investor_properties_created_at
Партиционирование / retentionnone (портфели — единицы-десятки записей на инвестора, ADR-0056); бессрочное хранение (история заработка), soft-delete
Concurrencyоптимистическая блокировка (правка не должна затирать чужую); consumer-гонки гасятся unique-индексами идемпотентности (DealId, ExternalOwnershipId, active-по-объекту недвижимости)
Permissions / PIIЦены покупки/продажи и доход — чувствительные финансовые данные инвестора: чтение только владельцем (record-level InvestorUserId = actor, permission investor-owner.portfolio.view-self); Служба заботы / Менеджер ПИН — investor-owner.portfolio.view (платформенный scope, с аудитом доступа); admin-правки — investor-owner.portfolio.manage; суммы не пишутся в логи открытым текстом
Auditотметки создания/изменения/удаления (кто и когда); продажа и admin-исправления — доменные события; ошибочные записи не удаляются физически (soft-delete + компенсирующая запись)

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

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

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