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

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
KindAggregateRoot (гибрид с репликой Talent)
Source of truthchessboard — статус/таймеры/цена для PIN; talent.microservices.chessboard — реплика-поля; crm — инициатор переходов статуса (событиями)
Таблицаchessboard.property_objects
Tenant isolationTenantId = DeveloperId проекта (ADR-0052); публичное чтение — доступно, но Hidden-объекты недвижимости не отдаются

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKapp / Talent syncИдентификатор объекта недвижимости.
TenantIdGuidДаindex ix_property_objects_tenant_id; = Project.TenantIdappTenant boundary (ADR-0052).
ProjectIdGuidДаFK fk_property_objects_projectchessboard.projects.id, indexappПроект (денормализация от здания — прямые запросы «все объекты недвижимости проекта» и ключ измерений dev-stats; INV-CHS-005).
BuildingIdGuidДаFK fk_property_objects_buildingchessboard.buildings.id, index ix_property_objects_building_floor (building_id, floor, section)appЗдание (литер/корпус).
LayoutIdGuid?НетFK fk_property_objects_layoutchessboard.layouts.id, index where not nullapp / Talent syncПланировка объекта недвижимости (layout.md); из неё наследуются схемы и типовые площади.
StatusPropertyObjectStatusДа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.
NumberstringДаvarchar(32), min 1; unique ux_property_objects_building_number (building_id, lower(number)) where deleted_at is nullTalent sync / appНомер объекта («квартира 145», «номер 12-А»). Уникален в здании (INV-CHS-006).
FloorshortДаcheck floor between -5 and 200; <= Building.FloorsTotal (INV-BLD-3)Talent sync / appЭтаж — ось Y шахматки.
Sectionshort?Нетcheck section between 1 and 50; <= Building.SectionsCountTalent sync / appСекция/подъезд — ось X шахматки.
RoomsshortДаcheck rooms between 0 and 20; index ix_property_objects_rooms; 0 = студияTalent sync / appКомнатность (фильтр шахматки/каталога).
TotalAreadecimalДаnumeric(8,2), check total_area > 0Talent sync / appОбщая площадь, м² (может отличаться от типовой по обмерам).
LivingAreadecimal?Нетnumeric(8,2), check living_area > 0 and living_area <= total_areaTalent sync / appЖилая площадь, м².
CeilingHeightMetersdecimal?Нетnumeric(4,2), check between 2 and 10Talent sync / appВысота потолков; null → берётся Building.CeilingHeightMeters.
PricedecimalДаnumeric(14,2), check price >= 0; index ix_property_objects_priceTalent sync / Developers APIТекущая цена. Каждое изменение публикует PropertyObjectPriceChangedKafkaEvent (история — invest-analytics).
CurrencyIdshortДалогическая ссылка на справочник валют (реплика handbook-replica.md, без FK); = Project.CurrencyId (INV-PRJ-6)appВалюта цены (наследуется от проекта; RUB-only, ADR-0048).
PricePerMeterdecimalДаnumeric(12,2), >= 0вычисляется: round(Price / TotalArea, 2) при каждой смене Price/TotalAreaЦена за м² (денормализация для сортировок/фильтров без вычислений в запросе).
WindowViewsPropertyObjectWindowViewsDocument (jsonb)Да (дефолт пустой)jsonb, typedTalent sync / appВиды из окон — см. JSONB.
Capacityshort?Нетcheck capacity between 1 and 20Talent sync / appВместимость (гостей) — для апарт-отелей.
Descriptionstring?Нетvarchar(4000)appПубличное описание объекта недвижимости (PIN-поле).
ReservedUntilDateTimeOffset?Нетзаполнен ⇔ Status = Reserved (INV-CHS-002); index ix_property_objects_reserved_until where not nullconsumer CRMМомент истечения резерва (таймер 1 час от заявки на бронирование; источник значения — событие CRM).
BookedUntilDateTimeOffset?Нетзаполнен ⇔ Status = Booked (INV-CHS-002); index ix_property_objects_booked_until where not nullconsumer CRMМомент истечения брони (по программе бронирования застройщика: 4 часа / 10 дней и т.п.).
HoldDealIdGuid?Нетбез FK (чужой контекст); заполнен ⇔ Status in (Reserved, Booked)consumer CRMId сделки/бронирования CRM, удерживающей объект недвижимости; ключ идемпотентности consumer-а и защита от «чужого» release (INV-CHS-003).
SoldAtDateTimeOffset?Нетзаполнен ⇔ Status = Soldconsumer CRM / Talent syncМомент продажи.
StatusChangedAtDateTimeOffsetДаappМомент последнего перехода Status (для таймлайнов и диагностики залипших таймеров).
StatusSourcePropertyObjectStatusSourceДаenum (текст), varchar(32)appКто выполнил последний переход: Crm / TalentSync / DeveloperApi / ExpirationJobreference-data.md.
ExternalTalentPropertyObjectIdGuid?Нетunique ux_property_objects_external_talent_property_object_id (where not null)Talent syncId объекта в Talent Chessboard.
SyncSourceChessboardSyncSourceДаenum (текст), varchar(32)app / consumerPin / TalentChessboard (реплика-поля: Number, Floor, Section, Rooms, площади, WindowViews, LayoutId-привязка).
ReplicaVersionlong?Нет (обязателен при реплике)>= 0Talent syncAnti-out-of-order guard.
LastSyncedAtDateTimeOffset?НетTalent syncПоследний применённый синк.
CreatedAtDateTimeOffsetДаindex ix_property_objects_created_atappДата создания.
CreatedByUserIdGuid?НетappКто создал.
UpdatedAtDateTimeOffsetДаappДата изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?Нетpartial-index filterappSoft-delete.
DeletedByUserIdGuid?НетappКто удалил.
VersionlongДа>= 1appВерсия агрегата (в payload событий).

Enum PropertyObjectStatus (хранится текстом)

ЗначениеРусский термин (глоссарий)Описание
FreeСвободенДоступен к покупке: показывается в шахматке зелёным, можно резервировать.
ReservedРезервКраткая блокировка (таймер ReservedUntil, 1 час) после заявки на бронирование.
BookedБроньПодтверждённое бронирование по программе застройщика (таймер BookedUntil).
SoldПроданСделка завершена; терминальный статус.
HiddenСкрытНе отображается в публичных проекциях (снят с продажи застройщиком); в шахматке застройщика виден серым.

Модели JSONB (versioned)

PropertyObjectWindowViewsDocument (jsonb, колонка window_views, ContractVersion = 1)

ПолеТипОбязательностьОграниченияSince version
ContractVersionintДа= 1v1
ViewsIReadOnlyList<WindowViewKind> (enum-string)Да (может быть пустым)значения enum WindowViewKind (reference-data.md): Sea / City / Courtyard / Mountains / Park / River / Street; ≤ 4 элементов, без дублейv1
PanoramaUrlstring?Нетvarchar(512), URL — 3D-тур/панорама видаv1
Неизвестное значение enum

Unknown enum-значение из синка Talent маппится в Street c warning-логом (правило unknown enum handling).

Связи

СвязьТипКлючDelete behavior
ProjectEntityreferenceproperty_objects.project_id → projects.idrestrict
BuildingEntityreference (родитель по структуре)property_objects.building_id → buildings.idrestrict
LayoutEntityreferenceproperty_objects.layout_id → layouts.idархив планировки блокируется при живых объектах недвижимости (см. layout.md)
Сделка CRM (hold)логическая (без FK)HoldDealId
Talent PropertyObjectreplicaExternalTalentPropertyObjectId

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

ИнвариантУсловиеКогда проверяетсяОшибка
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 TimerConsistencyReservedUntil != nullStatus = Reserved; BookedUntil != nullStatus = Booked; при любом другом статусе оба таймера nullпри переходах статуса (таймеры ставятся/чистятся атомарно)CHESSBOARD_PROPERTY_OBJECT_TIMER_STATE_INVALID
INV-CHS-003 HoldOwnershipRelease/Book/MarkSold от CRM принимаются только при event.DealId == HoldDealId (кроме MarkSold из Free синком)при применении события CRMCHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH (событие в DLQ c алертом)
INV-CHS-004 TenantFromProjectTenantId == Project.TenantId == Building.TenantIdпри созданииCHESSBOARD_PROPERTY_OBJECT_TENANT_MISMATCH
INV-CHS-005 BuildingBelongsToProjectBuilding.ProjectId == ProjectIdпри созданииCHESSBOARD_PROPERTY_OBJECT_BUILDING_PROJECT_MISMATCH
INV-CHS-006 UniqueNumberInBuildingNumber уникален среди не удалённых объектов недвижимости зданияпри создании и переименованииCHESSBOARD_PROPERTY_OBJECT_NUMBER_TAKEN
INV-CHS-007 LayoutSameProjectLayout.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; триггерит ProjectAggregatesRecalcRequestedEventId, 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)Каждый переход StatusPropertyObjectId, 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
Партиционирование / retentionnone — сотни тысяч строк максимум (ADR-0056); retention бессрочный (soft-delete)
Concurrencyоптимистическая блокировка: гонка «CRM-событие vs правка застройщика» разрешается повтором consumer-а; процесс истечения берёт объекты недвижимости без взаимных блокировок
Permissions / PIIPublic-чтение: неавторизованным — ограниченная карточка без точной цены/статуса, авторизованным — полная; Hidden публично не отдаётся; мутации паспортных полей/цены — chessboard.property-objects.manage (scope застройщика); смена статусов — только system-actor (consumer CRM/процесс истечения). PII нет
Auditверсия агрегата + отметки кто/когда + StatusSource/StatusChangedAt; полная история статусов и цен восстанавливается из событий

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

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

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

  • building.md, project.md — родители; layout.md — планировка; reference-data.mdPropertyObjectStatusSource, 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/ (consumer pin.chessboard.property-object-price-changed.v1).
  • ADR-0052, ADR-0056.