Entity / Aggregate: PropertyObjectEntity
Дом полей агрегата «Объект недвижимости» контекста chessboard. Все проекции (шахматка,
ApartmentListItem/ApartmentDetail витрины catalog, история цен invest-analytics, измерение
PropertyObject в dev-stats, payload событий) ссылаются сюда и описывают только дельту.
Назначение
PropertyObjectEntity — единица недвижимости в проекте застройщика: квартира, апартамент, номер. Несёт
номер, этаж, площади (общая/жилая), комнатность, цену и цену за м², статус продажи с таймерами, вид из
окна, вместимость (для отелей). Является ячейкой шахматки (Floor × Section внутри Building).
Статус продажи управляется извне: переходы Резерв/Бронь/Продан инициируются событиями CRM
(pin.crm.*), а не API шахматки; шахматка — source of truth текущего статуса и таймеров для всех читателей
(catalog, витрины). Сущность гибридная: базовые поля реплицируются из внешней системы Talent Chessboard,
PIN дополняет и корректирует локальные поля.
Классификация
| Поле | Значение |
|---|---|
| Контекст | chessboard |
| Kind | AggregateRoot (гибрид с репликой Talent) |
| Source of truth | chessboard — статус/таймеры/цена для PIN; talent.microservices.chessboard — реплика-поля; crm — инициатор переходов статуса (событиями) |
| Таблица | chessboard.property_objects |
| Tenant isolation | TenantId = DeveloperId проекта (ADR-0052); публичное чтение — доступно, но Hidden-объекты недвижимости не отдаются |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app / Talent sync | Идентификатор объекта недвижимости. |
TenantId | Guid | Да | index ix_property_objects_tenant_id; = Project.TenantId | app | Tenant boundary (ADR-0052). |
ProjectId | Guid | Да | FK fk_property_objects_project → chessboard.projects.id, index | app | Проект (денормализация от здания — прямые запросы «все объекты недвижимости проекта» и ключ измерений dev-stats; INV-CHS-005). |
BuildingId | Guid | Да | FK fk_property_objects_building → chessboard.buildings.id, index ix_property_objects_building_floor (building_id, floor, section) | app | Здание (литер/корпус). |
LayoutId | Guid? | Нет | FK fk_property_objects_layout → chessboard.layouts.id, index where not null | app / Talent sync | Планировка объекта недвижимости (layout.md); из неё наследуются схемы и типовые площади. |
Status | PropertyObjectStatus | Да | enum (текст), varchar(32), index ix_property_objects_status; составной ix_property_objects_project_status (project_id, status) | app / consumer CRM | Статус продажи: Free (Свободен) / Reserved (Резерв) / Booked (Бронь) / Sold (Продан) / Hidden (Скрыт). Переходы — INV-CHS-001. |
Number | string | Да | varchar(32), min 1; unique ux_property_objects_building_number (building_id, lower(number)) where deleted_at is null | Talent sync / app | Номер объекта («квартира 145», «номер 12-А»). Уникален в здании (INV-CHS-006). |
Floor | short | Да | check floor between -5 and 200; <= Building.FloorsTotal (INV-BLD-3) | Talent sync / app | Этаж — ось Y шахматки. |
Section | short? | Нет | check section between 1 and 50; <= Building.SectionsCount | Talent sync / app | Секция/подъезд — ось X шахматки. |
Rooms | short | Да | check rooms between 0 and 20; index ix_property_objects_rooms; 0 = студия | Talent sync / app | Комнатность (фильтр шахматки/каталога). |
TotalArea | decimal | Да | numeric(8,2), check total_area > 0 | Talent sync / app | Общая площадь, м² (может отличаться от типовой по обмерам). |
LivingArea | decimal? | Нет | numeric(8,2), check living_area > 0 and living_area <= total_area | Talent sync / app | Жилая площадь, м². |
CeilingHeightMeters | decimal? | Нет | numeric(4,2), check between 2 and 10 | Talent sync / app | Высота потолков; null → берётся Building.CeilingHeightMeters. |
Price | decimal | Да | numeric(14,2), check price >= 0; index ix_property_objects_price | Talent sync / Developers API | Текущая цена. Каждое изменение публикует PropertyObjectPriceChangedKafkaEvent (история — invest-analytics). |
CurrencyId | short | Да | логическая ссылка на справочник валют (реплика handbook-replica.md, без FK); = Project.CurrencyId (INV-PRJ-6) | app | Валюта цены (наследуется от проекта; RUB-only, ADR-0048). |
PricePerMeter | decimal | Да | numeric(12,2), >= 0 | вычисляется: round(Price / TotalArea, 2) при каждой смене Price/TotalArea | Цена за м² (денормализация для сортировок/фильтров без вычислений в запросе). |
WindowViews | PropertyObjectWindowViewsDocument (jsonb) | Да (дефолт пустой) | jsonb, typed | Talent sync / app | Виды из окон — см. JSONB. |
Capacity | short? | Нет | check capacity between 1 and 20 | Talent sync / app | Вместимость (гостей) — для апарт-отелей. |
Description | string? | Нет | varchar(4000) | app | Публичное описание объекта недвижимости (PIN-поле). |
ReservedUntil | DateTimeOffset? | Нет | заполнен ⇔ Status = Reserved (INV-CHS-002); index ix_property_objects_reserved_until where not null | consumer CRM | Момент истечения резерва (таймер 1 час от заявки на бронирование; источник значения — событие CRM). |
BookedUntil | DateTimeOffset? | Нет | заполнен ⇔ Status = Booked (INV-CHS-002); index ix_property_objects_booked_until where not null | consumer CRM | Момент истечения брони (по программе бронирования застройщика: 4 часа / 10 дней и т.п.). |
HoldDealId | Guid? | Нет | без FK (чужой контекст); заполнен ⇔ Status in (Reserved, Booked) | consumer CRM | Id сделки/бронирования CRM, удерживающей объект недвижимости; ключ идемпотентности consumer-а и защита от «чужого» release (INV-CHS-003). |
SoldAt | DateTimeOffset? | Нет | заполнен ⇔ Status = Sold | consumer CRM / Talent sync | Момент продажи. |
StatusChangedAt | DateTimeOffset | Да | — | app | Момент последнего перехода Status (для таймлайнов и диагностики залипших таймеров). |
StatusSource | PropertyObjectStatusSource | Да | enum (текст), varchar(32) | app | Кто выполнил последний переход: Crm / TalentSync / DeveloperApi / ExpirationJob — reference-data.md. |
ExternalTalentPropertyObjectId | Guid? | Нет | unique ux_property_objects_external_talent_property_object_id (where not null) | Talent sync | Id объекта в Talent Chessboard. |
SyncSource | ChessboardSyncSource | Да | enum (текст), varchar(32) | app / consumer | Pin / TalentChessboard (реплика-поля: Number, Floor, Section, Rooms, площади, WindowViews, LayoutId-привязка). |
ReplicaVersion | long? | Нет (обязателен при реплике) | >= 0 | Talent sync | Anti-out-of-order guard. |
LastSyncedAt | DateTimeOffset? | Нет | — | Talent sync | Последний применённый синк. |
CreatedAt | DateTimeOffset | Да | index ix_property_objects_created_at | app | Дата создания. |
CreatedByUserId | Guid? | Нет | — | app | Кто создал. |
UpdatedAt | DateTimeOffset | Да | — | app | Дата изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший. |
DeletedAt | DateTimeOffset? | Нет | partial-index filter | app | Soft-delete. |
DeletedByUserId | Guid? | Нет | — | app | Кто удалил. |
Version | long | Да | >= 1 | app | Версия агрегата (в payload событий). |
Enum PropertyObjectStatus (хранится текстом)
| Значение | Русский термин (глоссарий) | Описание |
|---|---|---|
Free | Свободен | Доступен к покупке: показывается в шахматке зелёным, можно резервировать. |
Reserved | Резерв | Краткая блокировка (таймер ReservedUntil, 1 час) после заявки на бронирование. |
Booked | Бронь | Подтверждённое бронирование по программе застройщика (таймер BookedUntil). |
Sold | Продан | Сделка завершена; терминальный статус. |
Hidden | Скрыт | Не отображается в публичных проекциях (снят с продажи застройщиком); в шахматке застройщика виден серым. |
Модели JSONB (versioned)
PropertyObjectWindowViewsDocument (jsonb, колонка window_views, ContractVersion = 1)
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|---|---|---|---|
ContractVersion | int | Да | = 1 | v1 |
Views | IReadOnlyList<WindowViewKind> (enum-string) | Да (может быть пустым) | значения enum WindowViewKind (reference-data.md): Sea / City / Courtyard / Mountains / Park / River / Street; ≤ 4 элементов, без дублей | v1 |
PanoramaUrl | string? | Нет | varchar(512), URL — 3D-тур/панорама вида | v1 |
Unknown enum-значение из синка Talent маппится в Street c warning-логом (правило unknown enum handling).
Связи
| Связь | Тип | Ключ | Delete behavior |
|---|---|---|---|
ProjectEntity | reference | property_objects.project_id → projects.id | restrict |
BuildingEntity | reference (родитель по структуре) | property_objects.building_id → buildings.id | restrict |
LayoutEntity | reference | property_objects.layout_id → layouts.id | архив планировки блокируется при живых объектах недвижимости (см. layout.md) |
| Сделка CRM (hold) | логическая (без FK) | HoldDealId | — |
Talent PropertyObject | replica | ExternalTalentPropertyObjectId | — |
Инварианты и переходы состояний
| Инвариант | Условие | Когда проверяется | Ошибка |
|---|---|---|---|
| INV-CHS-001 StatusTransitionGuard | Переходы Status — только по матрице stateDiagram ниже; переходы Free → Reserved → Booked → Sold и releases инициируются только событиями CRM (StatusSource = Crm) либо процессом истечения (ExpirationJob); Developers API может лишь Free ↔ Hidden; синк Talent может Free → Sold (продажа вне PIN) | при каждом переходе статуса (guard по текущему Status + допустимый StatusSource) | CHESSBOARD_PROPERTY_OBJECT_INVALID_STATUS_TRANSITION |
| INV-CHS-002 TimerConsistency | ReservedUntil != null ⇔ Status = Reserved; BookedUntil != null ⇔ Status = Booked; при любом другом статусе оба таймера null | при переходах статуса (таймеры ставятся/чистятся атомарно) | CHESSBOARD_PROPERTY_OBJECT_TIMER_STATE_INVALID |
| INV-CHS-003 HoldOwnership | Release/Book/MarkSold от CRM принимаются только при event.DealId == HoldDealId (кроме MarkSold из Free синком) | при применении события CRM | CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH (событие в DLQ c алертом) |
| INV-CHS-004 TenantFromProject | TenantId == Project.TenantId == Building.TenantId | при создании | CHESSBOARD_PROPERTY_OBJECT_TENANT_MISMATCH |
| INV-CHS-005 BuildingBelongsToProject | Building.ProjectId == ProjectId | при создании | CHESSBOARD_PROPERTY_OBJECT_BUILDING_PROJECT_MISMATCH |
| INV-CHS-006 UniqueNumberInBuilding | Number уникален среди не удалённых объектов недвижимости здания | при создании и переименовании | CHESSBOARD_PROPERTY_OBJECT_NUMBER_TAKEN |
| INV-CHS-007 LayoutSameProject | Layout.ProjectId == ProjectId при привязке | при привязке планировки | CHESSBOARD_PROPERTY_OBJECT_LAYOUT_PROJECT_MISMATCH |
| INV-CHS-008 PriceChangeGuard | Изменение Price запрещено при Status = Sold; при Reserved/Booked — допускается только Developers API c warning-событием (цена в сделке фиксируется CRM отдельно) | при изменении цены | CHESSBOARD_PROPERTY_OBJECT_PRICE_LOCKED |
| INV-CHS-009 HideGuard | Скрытие допустимо только из Free; Reserved/Booked/Sold скрыть нельзя | при скрытии | CHESSBOARD_PROPERTY_OBJECT_HIDE_FORBIDDEN |
Source of truth таймеров — CRM (программа бронирования); шахматка хранит ReservedUntil/BookedUntil для
отображения обратного отсчёта и запускает страховочный фоновый процесс
property-object-hold-expiration: находит
объекты недвижимости в Reserved/Booked с истёкшим таймером (+grace 10 мин) и переводит их в Free
(StatusSource = ExpirationJob) с warning-логом (значит, release-событие CRM потеряно). Сообщения в чат об
истечении — ответственность CRM/messenger.
Конкурентность и идемпотентность переходов статуса
Переходы статуса приходят асинхронно (события CRM, синк Talent, страховочный процесс), а паспортные поля и цену параллельно правит застройщик — все меняют один агрегат под оптимистической блокировкой. Правила разрешения конфликтов и повторов:
- Единый источник статуса. Статусные переходы
Free → Reserved → Booked → Soldи release-ы инициирует только CRM либо процесс истечения (INV-CHS-001); Developers API правит лишьFree ↔ Hiddenи паспортные поля/цену. Поэтому «двух конкурирующих источников статуса» нет — гонка возможна лишь между сменой статуса и правкой паспортных полей/цены. - Идемпотентность применения событий CRM (ключ
HoldDealId, INV-CHS-003). Повторная доставка того жеReserve/Book/Release/MarkSoldдля той же сделки — no-op: объект уже в целевом статусе, холд тот же. Событие другой сделки при занятом холде (event.DealId != HoldDealId) →..._HOLD_MISMATCH, сообщение в DLQ с алертом (потерян release предыдущей сделки либо конфликт CRM). - Порядок и version-guard. События одного объекта недвижимости упорядочены ключом
PropertyObjectId; устаревший (меньшая доменная версия) переход отбрасывается монотонным guard-ом. Между разными топиками агрегата (upserted/status-changed/price-changed) порядок не гарантирован — потребители держат общий version-guard. - Проигравший повторяет. При гонке «переход CRM ↔ правка застройщика» операция с устаревшей версией агрегата откатывается и повторяется поверх свежего состояния: правка застройщика не затирает статус, наложенный CRM, а применение события CRM не теряет одновременную правку паспортного поля. Consumer CRM при конфликте версий просто перечитывает и переприменяет (естественный retry).
- Гонка release CRM ↔ таймер истечения. Если release-событие CRM и срабатывание страховочного таймера
сходятся, побеждает первый по порядку: второй видит объект уже в
Freeпо своей сделке → no-op; если холд за это время сменился —..._HOLD_MISMATCH(в DLQ). Оба пути ведут объект вFree, «залипания» нет.
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
PropertyObjectCreatedEvent | доменное | Успешное создание | Id, ProjectId, BuildingId |
PropertyObjectStatusChangedEvent | доменное | Любой переход Status; триггерит ProjectAggregatesRecalcRequestedEvent | Id, From, To, StatusSource |
PropertyObjectUpsertedKafkaEvent | интеграционное (ContractVersion = 1, топик pin.chessboard.property-object-upserted.v1, key = Id) | Создание и обновление паспортных полей (Number/Floor/Rooms/площади/LayoutId/WindowViews/Capacity) | Snapshot: Id, TenantId, ProjectId, BuildingId, LayoutId, LayoutGroupId (через layout), Status, Number, Floor, Section, Rooms, TotalArea, LivingArea, Price, CurrencyId + CurrencySnapshot (snapshots.md), PricePerMeter, WindowViews.Views, Capacity, Version. Потребители: catalog, dev-stats (измерение PropertyObject), rewards |
PropertyObjectStatusChangedKafkaEvent | интеграционное (ContractVersion = 1, топик pin.chessboard.property-object-status-changed.v1, key = Id) | Каждый переход Status | PropertyObjectId, TenantId, ProjectId, BuildingId, FromStatus, ToStatus, StatusSource, ReservedUntil, BookedUntil, HoldDealId, SoldAt, StatusChangedAt, Version. Потребители: catalog (индикация шахматки/витрины), favorites/selections (пометка «продан»), notifications |
PropertyObjectPriceChangedKafkaEvent | интеграционное (ContractVersion = 1, топик pin.chessboard.property-object-price-changed.v1, key = Id) | Каждое изменение Price (в т.ч. синком) | PropertyObjectId, TenantId, ProjectId, OldPrice, NewPrice, CurrencyId + CurrencySnapshot, PricePerMeter, ChangedAt, Source (PropertyObjectStatusSource), Version. Потребители: invest-analytics (append-only история цен, график роста), catalog |
Интеграционные события публикуются атомарно со сменой состояния агрегата.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK pk_property_objects; ux_property_objects_building_number unique (building_id, lower(number)) where deleted_at is null; ux_property_objects_external_talent_property_object_id unique where not null; ix_property_objects_tenant_id; ix_property_objects_project_status (project_id, status); ix_property_objects_building_floor (building_id, floor, section) — отрисовка шахматки одним range-scan; ix_property_objects_layout_id where not null; ix_property_objects_rooms; ix_property_objects_price; ix_property_objects_reserved_until where not null и ix_property_objects_booked_until where not null — выборка процесса истечения; ix_property_objects_created_at |
| Партиционирование / retention | none — сотни тысяч строк максимум (ADR-0056); retention бессрочный (soft-delete) |
| Concurrency | оптимистическая блокировка: гонка «CRM-событие vs правка застройщика» разрешается повтором consumer-а; процесс истечения берёт объекты недвижимости без взаимных блокировок |
| Permissions / PII | Public-чтение: неавторизованным — ограниченная карточка без точной цены/статуса, авторизованным — полная; Hidden публично не отдаётся; мутации паспортных полей/цены — chessboard.property-objects.manage (scope застройщика); смена статусов — только system-actor (consumer CRM/процесс истечения). PII нет |
| Audit | версия агрегата + отметки кто/когда + StatusSource/StatusChangedAt; полная история статусов и цен восстанавливается из событий |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (6): Entity / Aggregate: BuildingEntity, Entity / Aggregate: CatalogPropertyObjectCard, Entity / Aggregate: InvestorPropertyEntity, Entity / Aggregate: LayoutEntity, Entity / Aggregate: ProjectEntity, Enum-справочники контекста chessboard
- API (15): Chessboard API — реестр операций областей Developers и Admin, GET /api/v1/developers/projects/{projectId}/property-objects — Список объектов недвижимости проекта, GET /api/v1/developers/property-objects/{propertyObjectId} — Карточка объекта недвижимости (кабинет застройщика), GET /api/v1/public/projects/{projectId}/chessboard — Шахматка проекта (матрица этажи × секции), GET /api/v1/public/property-objects — Список объектов недвижимости проекта/здания с фильтрами, GET /api/v1/public/property-objects/{propertyObjectId} — Карточка объекта/объекта недвижимости (ограниченная/полная), POST /api/v1/developers/buildings/{buildingId}/property-objects — Создание объекта недвижимости, POST /api/v1/developers/buildings/{buildingId}/property-objects/bulk — Массовый upsert объектов недвижимости, POST /api/v1/developers/layouts/{layoutId}/archive — Архивирование планировки, POST /api/v1/developers/property-objects/{propertyObjectId}/hide — Скрытие объекта недвижимости, POST /api/v1/developers/property-objects/{propertyObjectId}/price — Изменение цены объекта недвижимости, POST /api/v1/developers/property-objects/{propertyObjectId}/unhide — Возврат объекта недвижимости в продажу (+3)
- Use Cases (6): UC-CHS-002. Добавление здания (литера/корпуса) и объектов проекта застройщиком, UC-CHS-003. Изменение цены объекта застройщиком (события в аналитику), UC-CHS-004. Просмотр шахматки проекта: гость vs инвестор (разница полей), UC-CHS-005. Синхронизация реплики из Talent Chessboard (system), UC-CHS-006. Резерв/бронь/продажа меняет статус объекта (system, события CRM), UC-OWN-004. Карточка объекта портфеля с историей изменения цен
- События (3): Event Contract: pin.chessboard.property-object-price-changed (PropertyObjectPriceChangedKafkaEvent), Event Contract: pin.chessboard.property-object-status-changed (PropertyObjectStatusChangedKafkaEvent), Event Contract: pin.chessboard.property-object-upserted (PropertyObjectUpsertedKafkaEvent)
- Консюмеры (3): Consumer: Chessboard Projection Sync (pin-catalog-chessboard-sync), Consumer: CRM PropertyObject Status (pin-chessboard-crm-property-object-status), Consumer: Talent Chessboard Sync (pin-chessboard-talent-sync)
- Фоновые задачи (3): Background Job: project-aggregates-recalc (пересчёт денормализованных агрегатов), Background Job: property-object-hold-expiration (страховка от потерянных release-событий CRM), Background Job: talent-initial-import (первичная загрузка / bootstrap из Talent Chessboard)
- index.md (1): Chessboard Service (Pin.Chessboard)
- dimensions (1): dim_property_object (ClickHouse dimension)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- building.md, project.md — родители; layout.md — планировка;
reference-data.md —
PropertyObjectStatusSource,WindowViewKind,ChessboardSyncSource. - handbook-replica.md — справочник валют (
CurrencyId);CurrencySnapshotисходящих событий — ../../handbook/domain/snapshots.md. - Consumer статусов CRM: ../consumers/crm-property-object-status.md;
топики CRM —
pin.crm.booking-created/booking-confirmed/booking-expired/booking-cancelled.v1иpin.crm.deal-completed/deal-cancelled.v1(маппинг «событие CRM → переход Резерв/Бронь/Освобождение/ Продан» — в консюмере; контракты —docs/06-services/crm/events/). - История цен:
docs/06-services/invest-analytics/(consumerpin.chessboard.property-object-price-changed.v1). - ADR-0052, ADR-0056.