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):
PinCrmDeal— после завершения сделки в CRM (consumerpin.crm.deal-completed.v1, ../consumers/crm-deal-completed.md) купленный объект появляется у инвестора;TalentOwnersSync— объекты собственника из Talent Owners, привязанные через owner-mapping.md (OwnerMappingId).
Снапшот-поля объекта недвижимости (PropertyObjectSnapshot) денормализуются для отображения списка «Мои объекты недвижимости» без запросов в
chessboard; source of truth по структуре объекта недвижимости — chessboard
(../../chessboard/domain/property-object.md).
Классификация
| Поле | Значение |
|---|---|
| Контекст | investor-owner |
| Kind | AggregateRoot |
| 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) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор записи портфеля. |
InvestorUserId | Guid | Да | index ix_investor_properties_investor_user_id; ссылка на identity.users(id) без FK (чужой контекст) | consumer (deal-completed: InvestorUserId сделки; owners-sync: OwnerMapping.InvestorUserId) | Владелец записи; record-level ключ изоляции. |
OwnerMappingId | Guid? | Нет (обязателен при Source = TalentOwnersSync) | FK fk_investor_properties_owner_mapping_id → investor_owner.owner_mappings(id), index ix_investor_properties_owner_mapping_id | consumer owners-sync | Связь с собственником Talent Owners. null для объектов из сделок PIN (INV-2). |
Status | OwnershipStatus | Да | хранится как text (varchar(32)), index ix_investor_properties_status | app | Active / Sold (enum ниже). |
Source | PropertySource | Да | хранится как text (varchar(32)) | consumer | Источник появления записи (enum ниже). Иммутабельно. |
DealId | Guid? | Нет (обязателен при 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). |
ExternalOwnershipId | Guid? | Нет (обязателен при Source = TalentOwnersSync) | unique ux_investor_properties_external_ownership_id where external_ownership_id is not null | событие синхронизации Talent Owners | Id записи владения во внешней системе; идемпотентность синхронизации объектов. |
PropertyObjectId | Guid | Да | index ix_investor_properties_property_object_id; ссылка на объект недвижимости chessboard без FK | consumer | Объект недвижимости; ключ для истории цен invest-analytics и карточки объекта недвижимости. |
ProjectId | Guid? | Нет | index ix_investor_properties_project_id | consumer / chessboard API | Проект (ЖК/отель) — денормализация для группировки портфеля. null, если объект Talent Owners не сопоставлен с шахматкой PIN. |
BuildingId | Guid? | Нет | — | consumer / chessboard API | Здание/литер — денормализация для карточки. |
DeveloperId | Guid? | Нет | — | consumer / chessboard API | Застройщик — денормализация для карточки. |
PropertyObjectSnapshot | PropertyObjectSnapshot (VO) | Да | см. VO ниже | payload события + chessboard API | Снапшот объекта недвижимости для отображения без cross-context запросов. |
PurchasePrice | Money (VO) | Да | Amount > 0 (INV-4) | deal-completed (итоговая цена сделки) / Talent Owners | За сколько купили. |
PurchasedAt | DateTimeOffset | Да | index ix_investor_properties_purchased_at; PurchasedAt <= now() | deal-completed (CompletedAt) / Talent Owners | Дата покупки (завершение сделки). |
SalePrice | Money? (VO) | Нет (обязателен при Status = Sold) | Amount > 0; CurrencyId = PurchasePrice.CurrencyId (INV-5) | синхронизация Talent Owners / admin-API / будущая сделка продажи CRM | За сколько продали. |
SoldAt | DateTimeOffset? | Нет (обязателен при Status = Sold) | SoldAt >= PurchasedAt (INV-3) | тот же источник, что SalePrice | Дата продажи; она же «когда заработали» (EarnedAt). |
RealizedProfit | Money? (VO) | Нет (обязателен при Status = Sold) | вычисляется агрегатом, не принимается извне | вычисление: SalePrice.Amount − PurchasePrice.Amount (валюта — общая) | Сколько заработали (может быть отрицательным — убыток). Фиксируется при продаже атомарно с SalePrice/SoldAt. |
CurrencyIsoCode | string | Да | 3 символа, ISO-4217; column currency_iso_code | consumer / admin-API: CurrencySnapshot.IsoCode входного события либо резолв справочника валют handbook | Денормализованный display-код единой валюты записи (INV-5), снятый при записи (правило снапшотов handbook). Только для отображения (сводка портфеля, сверка с кодом валюты оценки invest-analytics); в бизнес-логике не участвует. |
CreatedAt | DateTimeOffset | Да | index ix_investor_properties_created_at | app | Появление записи в портфеле. |
CreatedByUserId | Guid? | Нет | — | app | null — создано consumer-ом; иначе id актора admin-API. |
UpdatedAt | DateTimeOffset | Да | — | app | Момент последнего изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший. |
DeletedAt | DateTimeOffset? | Нет | — | app | Soft-delete (ручной архив ошибочной записи). |
DeletedByUserId | Guid? | Нет | — | app | Кто архивировал. |
Version | long | Да | >= 1 | app | Доменная версия записи (растёт при каждом изменении; входит в 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
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
Amount | decimal | Да | numeric(18,2); для RealizedProfit допускается < 0, для цен — > 0 | purchase_price_amount / sale_price_amount / realized_profit_amount |
CurrencyId | short | Да | ссылка на справочник валют handbook (../../handbook/domain/currency.md) без FK (чужой контекст); default — валюта RUB | purchase_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)
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
Title | string | Да | varchar(256), min 1 | property_object_snapshot_title |
Number | string? | Нет | varchar(64) | property_object_snapshot_number |
Project | ProjectRefModel? | Нет | ref-модель ниже; null — объект недвижимости не сопоставлен с шахматкой PIN | property_object_snapshot_project_* |
Building | BuildingRefModel? | Нет | ref-модель ниже; null при отсутствии сопоставления | property_object_snapshot_building_* |
AreaTotal | decimal? | Нет | numeric(10,2), > 0 | property_object_snapshot_area_total |
Floor | int? | Нет | >= -5, <= 200 | property_object_snapshot_floor |
SnapshotAt | DateTimeOffset | Да | — | 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 (проект — ЖК/отель)
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
ProjectId | Guid | Да | — | property_object_snapshot_project_id |
ProjectName | string | Да | varchar(256) | property_object_snapshot_project_name |
BuildingRefModel (корпус — здание/литер)
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
BuildingId | Guid | Да | — | property_object_snapshot_building_id |
BuildingName | string | Да | 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 |
|---|---|---|---|---|
OwnerMappingEntity | reference | OwnerMappingId (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 null | unique ux_investor_properties_investor_property_object_active + проверка в consumer-ах до вставки | INVESTOR_OWNER_PROPERTY_ALREADY_ACTIVE |
| INV-2 SourceConsistency | Source = 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 SoldCompleteness | Status = Sold ⇒ SalePrice != null ∧ SoldAt != null ∧ RealizedProfit != null ∧ SoldAt >= PurchasedAt | при переводе записи в Sold (единственный путь в статус) | INVESTOR_OWNER_SALE_DATA_INCOMPLETE |
| INV-4 PositivePrices | PurchasePrice.Amount > 0; SalePrice.Amount > 0 (при наличии) | при создании и продаже; валидация входа consumer-ов | INVESTOR_OWNER_PRICE_INVALID |
| INV-5 SingleCurrency | SalePrice.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: проверка статуса связи OwnerMappingId | INVESTOR_OWNER_MAPPING_NOT_LINKED (retryable) |
| INV-9 ImmutablePurchase | PurchasePrice, 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 → Sold | Id, 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 |
| Партиционирование / retention | none (портфели — единицы-десятки записей на инвестора, 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 + компенсирующая запись) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: OwnerMappingEntity
- API (6): GET /api/v1/portfolio/properties — Мои объекты (getMyProperties), GET /api/v1/portfolio/properties/{propertyId} — Карточка объекта портфеля (getMyProperty), GET /api/v1/portfolio/summary — Сводка доходности портфеля (getMyPortfolioSummary), Investor-Owner API — реестр операций и общие контрактные модели, POST /api/v1/admin/portfolio/properties — Создание записи портфеля вручную (admin, компенсирующее), POST /api/v1/admin/portfolio/properties/{propertyId}/archive — Архивирование записи портфеля (admin, archiveInvestorProperty)
- Use Cases (4): UC-OWN-001. Синхронизация собственника из Talent Owners: owner → investor по телефону (system), UC-OWN-002. Завершение сделки в CRM → купленный объект в «Мои объекты недвижимости» (system), UC-OWN-003. Инвестор просматривает портфель «Мои объекты недвижимости» и доходность, UC-OWN-004. Карточка объекта портфеля с историей изменения цен
- События (2): Event Contract: pin.investor-owner.property-added (InvestorPropertyAddedKafkaEvent), Event Contract: pin.investor-owner.property-sold (InvestorPropertySoldKafkaEvent)
- Консюмеры (2): Consumer: завершённая сделка CRM → объект в портфеле инвестора (pin-investor-owner-crm-deal-completed), Consumer: синхронизация из Talent Owners (pin-investor-owner-talent-owners-sync)
- index.md (1): Сервис Инвестор-Собственник (investor-owner)
- registries (1): Реестр сущностей PIN
Связанные документы
- owner-mapping.md — связь с собственником Talent Owners.
- Consumers: ../consumers/crm-deal-completed.md, ../consumers/talent-owners-sync.md.
- API портфеля: ../api/public/get-my-properties.md, ../api/public/get-my-property.md, ../api/public/get-my-portfolio-summary.md.
- События: ../events/property-added.md, ../events/property-sold.md.
- Чужие дома: сделка —
docs/06-services/crm/(событиеpin.crm.deal-completed.v1); объект недвижимости —docs/06-services/chessboard/; история цен —docs/06-services/invest-analytics/; справочник валют и snapshot-модели — ../../handbook/domain/currency.md, ../../handbook/domain/snapshots.md. - Решения: ADR-0052 (инвестор вне tenant), ADR-0056 (simplicity-first), ADR-0048 (RUB-only —
CurrencyIdкак типизированная ссылка), ADR-0063 (справочники handbook, денормализованные display-поля).