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

Entity / Aggregate: CatalogPropertyObjectCard

Дом полей денормализованной карточки объекта недвижимости публичного каталога (контекст catalog). Модели ответов public API (лента /lots, карточка /lots/[id]/info), payload событий и поисковые проекции ссылаются сюда и описывают только дельту.

Назначение

CatalogPropertyObjectCardденормализованная витринная проекция объекта недвижимости для ленты /lots, ассортимента проекта /hotels/[id]/assortment и карточки /lots/[id]/info: все поля карточки отдаются из одной строки без джойнов в источники.

Правило «только дельта + ссылка на дом chessboard»

Source of truth структурных данных объекта недвижимости (номер, этаж, площади, комнатность, планировка, статус) — контекст chessboard (chessboard/domain/property-object.md). Этот документ владеет только витринной дельтой: эффективными (после оверрайдов) значениями отображения, метриками доходности из invest-analytics, комиссией из rewards и ранжированием. Колонка «Источник» в таблице полей указывает канонический дом каждого поля; переописывать поля источников в других документах запрещено.

Карточка обновляется только консюмерами и внутренними операциями каталога (оверрайд/ранжирование/ продвижение); прямых пользовательских мутаций нет. Правило вычисления каждого эффективного поля: Effective = Override(поле) ?? SourceSnapshot(поле) (оверрайды — display-override.md). Полнотекстовый поиск карточки вынесен в Elasticsearch-проекцию (../search/property-objects.md, ADR-0059) — tsvector/GIN здесь нет.

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

ПолеЗначение
Контекстcatalog
KindReplica (денормализованная read-проекция; мутируется консюмерами + пересчётом оверрайдов)
Базовые контрактыреплика-проекция, не агрегат — доменных инвариантов покупки/брони здесь нет, только правила проекции
Source of truthструктурные поля — chessboard; метрики — invest-analytics; комиссия — rewards; эффективные display-значения и ранжирование — этот контекст
Таблицаcatalog.property_object_cards
Tenant isolationне tenanted — платформенные данные витрины (ADR-0052 п.3); видимость решается Status/permissions; Developer.DeveloperId — обычная колонка для фильтров и ranking-scope
Search projectionElasticsearch property-objects (ADR-0059)

Поля

Легенда источников

chessboard — событие pin.chessboard.property-object-upserted.v1/pin.chessboard.building-upserted.v1 (дом полей — chessboard/domain/property-object.md, chessboard/domain/building.md); invest-analytics — событие pin.invest-analytics.property-object-metrics.v1 (invest-analytics/domain/); rewards — событие pin.rewards.commission-rules.v1 (rewards/domain/reward-rule.md); overridedisplay-override.md; catalog — вычисляется этим контекстом.

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKchessboard (PropertyObject.Id)Идентификатор объекта = id в chessboard.
ProjectIdGuidДаindex ix_property_object_cards_project_id; логическая ссылка на catalog.project_cards.id (без FK — порядок событий проектов/объектов не гарантирован; guard INV-2)chessboardПроект (ЖК/отель) объекта.
BuildingBuildingRefModel (owned)Дасм. VO; index ix_property_object_cards_building_idchessboardЗдание/литер/корпус (id + название).
DeveloperDeveloperRefModel (owned)Дасм. VO; index ix_property_object_cards_developer_idchessboardЗастройщик (id + имя): scope ранжирования, фильтр «девелопер», поисковая проекция.
NumberstringДаvarchar(32)chessboardНомер объекта.
TitlestringДаvarchar(512)override → chessboardЭффективный заголовок карточки (Override.Title ?? SourceSnapshot.Title).
Viewstring?Нетvarchar(64)chessboardВид из окон (море/город/двор).
Badgestring?Нетvarchar(64)override → chessboardМаркетинговый бейдж карточки («Хит», «Скидка»).
AddressstringДаvarchar(512)chessboard (проект)Адрес проекта, денормализован в объект. Индексируется в поисковой проекции.
DistanceMinutesint?Нет>= 0override (building) → chessboardМинуты до ключевой точки (море/центр).
Starsshort?Нет1..5override (building) → chessboardЗвёздность апарт-отеля.
DeliveryDateDateOnly?Нетindex ix_property_object_cards_delivery_dateoverride (building) → chessboardСрок сдачи.
ReadinessPercentshort?Нет0..100override (building) → chessboardГотовность строительства.
Pricedecimal(14,2)Да> 0, index ix_property_object_cards_priceoverride → chessboardЭффективная цена карточки (Override.Price ?? SourceSnapshot.Price). Сортировка «по цене» и фильтр «ценовой диапазон».
LayoutPropertyObjectLayoutModel (owned)Дасм. VOchessboard + catalogПланировочные характеристики (тип/комнатность/площади/этаж/потолки/планировка/цена за м²).
VisualizationFileIdGuid?Нетбез FK (внешний storage/CDN)chessboardФайл визуализации/планировки (пресайн — граница storage).
PropertyObjectStatusPropertyObjectAvailabilityStatusДахранится как text, varchar(32), index ix_property_object_cards_availabilitychessboardРеплика статуса доступности: Free / Reserved / Booked / Sold / Hidden (дом статуса и переходов — chessboard).
PaybackPropertyObjectPayback (owned)Нет (оба поля nullable вместе)см. VOoverride → invest-analyticsОкупаемость.
ReliabilityPropertyObjectReliability (owned)Нетсм. VOoverride → invest-analyticsИндекс надёжности.
GrowthPercentdecimal(5,2)?Нет-99.99..999.99invest-analyticsПрогнозный рост цены, %/год.
RentalIncomePerYeardecimal(14,2)?Нет>= 0invest-analyticsПрогнозный арендный доход, руб/год.
Ratingdecimal(3,1)?Нет0.0..10.0override → invest-analyticsРейтинг объекта.
AgentCommissionPropertyObjectAgentCommission (owned)Нетсм. VOrewardsДенормализованная комиссия агента; в ответах отдаётся только акторам с permission catalog.commissions.view.
DeveloperPriorityintДа0..1000, default 0, index composite (см. Индексы)catalog (ranking-rule.md)Приоритет застройщика в рамках его объектов.
PromotionBoostintДа>= 0, default 0consumer promotion (../consumers/promotion-boost.md)Суммарный вес активных пакетов продвижения (проектных + по объектам недвижимости); наполняется консюмером pin.promotion.purchase-changed.v1 (не владеется каталогом, ADR-0060). Ранжирование «Рекомендованные» остаётся у каталога (ranking-rule.md).
PopularityViewslongДа>= 0, default 0catalog (consumer pin.user-activity.property-object-view-ended.v1)Счётчик завершённых просмотров объекта (ADR-0056 п.3); дом первичных данных — user-activity/domain/property-object-view.md.
PopularityScoredecimal(12,4)Да>= 0, default 0, index ix_property_object_cards_popularitycatalog (job)Сглаженная популярность (просмотры за 30 дней с экспоненциальным затуханием); пересчёт ночным job-ом. Сортировка «по популярности».
StatusPropertyObjectCardStatusДахранится как text, varchar(32), index ix_property_object_cards_statuscatalog (правило видимости, INV-1)Published / Hidden / Delisted.
SourceSnapshotPropertyObjectSourceSnapshotDocument (jsonb)Даtyped jsonb, ContractVersionconsumer-ыПоследние сырые значения источников для идемпотентного пересчёта (см. JSONB).
PublishedAtDateTimeOffset?Нетindex ix_property_object_cards_published_atcatalogПервая публикация карточки; сортировка «по дате добавления».
ChessboardSyncedAt / AnalyticsSyncedAt? / RewardsSyncedAt?DateTimeOffsetДа / Нет / НетconsumerВремя применения последнего события chessboard / invest-analytics / rewards.
CreatedAt / UpdatedAtDateTimeOffsetДаappСоздание проекции / последний пересчёт.

Enum PropertyObjectCardStatus (хранится как text)

ЗначениеОписание
PublishedКарточка видима в ленте/поиске/карточке проекта.
HiddenСкрыта правилом видимости (INV-1): оверрайд IsVisible=false (объект или корпус), PropertyObjectStatus=Hidden у источника, скрытый/неопубликованный проект. Данные сохранены, из выдачи исключена.
DelistedОбъект удалён в источнике (chessboard прислал delete): карточка помечена и очищается retention-job-ом через 30 дней (ссылочная целостность избранного/подборок — по id, они переживают удаление).

Enum PropertyObjectAvailabilityStatus — реплика; дом значений и переходов — chessboard/domain/property-object.md

ЗначениеОтображение в каталоге
Free«Свободен» — кнопка «Забронировать» активна.
Reserved«Резерв» — таймер, бронирование недоступно.
Booked«Бронь».
Sold«Продан».
HiddenНе отображается (карточка → Hidden, INV-1).

Value objects

DeveloperRefModel / BuildingRefModel (owned) — ссылки id + имя

МодельПолеТипОграниченияКолонка
DeveloperRefModelDeveloperIdGuidindex ix_property_object_cards_developer_iddeveloper_id
DeveloperRefModelDeveloperNamestringvarchar(256); реплика из pin.identity.companies.v1 через project-card; индексируется в поисковой проекцииdeveloper_name
BuildingRefModelBuildingIdGuidindex ix_property_object_cards_building_idbuilding_id
BuildingRefModelBuildingNamestringvarchar(256)building_name

PropertyObjectLayoutModel (owned) — планировочные характеристики

ПолеТипОбязательностьОграниченияКолонка
TypeNamestringДаvarchar(64), index ix_property_object_cards_type_namelayout_type_name
RoomsCountshortДа>= 0 (0 = студия), index ix_property_object_cards_rooms_countlayout_rooms_count
AreaTotaldecimal(8,2)Да> 0layout_area_total
AreaLivingdecimal(8,2)?Нет> 0layout_area_living
PricePerMeterdecimal(12,2)Да> 0; round(Price / AreaTotal, 2) — пересчёт при изменении Price/AreaTotallayout_price_per_meter
FloorshortДа>= -5layout_floor
FloorsTotalshortДа>= 1, Floor <= FloorsTotal (check)layout_floors_total
CeilingHeightdecimal(4,2)?Нет2.00..10.00layout_ceiling_height
LayoutIdGuid?Нетindex ix_property_object_cards_layout_idlayout_id
LayoutGroupIdGuid?Нетlayout_group_id
Использование полей layout

TypeName — фильтр «тип» (квартира/апартамент/паркинг/коммерция); RoomsCount — фильтр объектов проекта; LayoutId/LayoutGroupId — ключ матчинга правил комиссии rewards.

PropertyObjectPayback (owned)

ПолеТипОбязательностьОграниченияКолонка
Valuedecimal(5,1)Да (внутри VO)> 0payback_value
PeriodPaybackPeriod (enum-string)ДаYears / Months, varchar(16)payback_period
Нормализация периода

Сортировка «по окупаемости» использует нормализацию в годы: Value при Period=Years, Value/12 при Period=Months (вычисляется в query, не хранится).

PropertyObjectReliability (owned)

ПолеТипОбязательностьОграниченияКолонка
ValueshortДа0..Max (check)reliability_value
MaxshortДа1..100reliability_max

PropertyObjectAgentCommission (owned) — денормализация правила rewards

ПолеТипОбязательностьОграниченияКолонка
PercentNullableRange<decimal>ДаMin/Max каждый 0.00..100.00, Min <= Max; decimal(5,2)commission_percent_min / commission_percent_max
BaseKindCommissionBaseKind (enum-string)ДаBasePrice (до ремонта/скидки) / FinalPrice (базовая + ремонт), varchar(32) — дом определения: rewards/domain/reward-rule.mdcommission_base_kind
RewardRuleIdGuidДабез FK (чужой контекст)commission_reward_rule_id
RuleVersionlongДа>= 1; guard out-of-order событий rewardscommission_rule_version
Диапазон комиссии

Percent.Min = Percent.Max, когда правило одно; диапазон появляется, когда несколько правил застройщика матчатся по условиям (тип/планировка/группа/комнатность/площадь) и точный процент зависит от программы сделки. null (весь VO) — комиссия для объекта не задана; фронт не показывает блок.

Модели JSONB (versioned)

PropertyObjectSourceSnapshotDocument (jsonb, колонка source_snapshot, ContractVersion = 1)

Сырые значения источников до применения оверрайдов — чтобы пересчёт эффективных полей при создании/изменении/удалении оверрайда не требовал запроса в чужой контекст, а консюмеры были идемпотентны по версиям.

ПолеТипОбязательностьОграниченияSince version
ContractVersionintДа= 1v1
chessboard-снимокДа/НетTitle string(512), Price decimal>0, Badge string?(64), Stars short?1..5, DeliveryDate DateOnly?, ReadinessPercent short?0..100, DistanceMinutes int?>=0 — исходные значения до оверрайдовv1
analytics-снимокНетPaybackValue decimal?>0, PaybackPeriod enum? Years/Months, ReliabilityValue short?, ReliabilityMax short?1..100, Rating decimal?0..10v1
ChessboardVersionlongДа>= 1; версия агрегата объекта в последнем применённом событии chessboard (guard INV-4)v1
AnalyticsAsOfDateTimeOffset?Нетмомент расчёта метрик в применённом событии invest-analyticsv1
Версионирование snapshot

Правила версии: additive-поля — той же версией (nullable/default); изменение семантики — ContractVersion + 1 + migration-reader. Секреты/PII в jsonb запрещены (здесь их нет).

Связи

СвязьТипКлючПравило загрузкиDelete behavior
CatalogProjectCardreference (логическая, без FK)property_object_cards.project_id → project_cards.idкарточка проекта при пересчёте видимости; лента объектов — без джойна (всё денормализовано)нет каскада: скрытие проекта → пересчёт Status объектов (INV-1)
PropertyObjectDisplayOverridereference (обратная, 0..1)property_object_display_overrides.property_object_id → property_object_cards.id (логическая)живой оверрайд объекта при пересчётеудаление оверрайда → пересчёт эффективных полей из SourceSnapshot
BuildingDisplayOverridereference (логическая, 0..1 на корпус)building_display_overrides.building_id = property_object_cards.building_idживой корпусной оверрайд при пересчётето же
chessboard.PropertyObjectreplica-источникIdconsumer pin.chessboard.property-object-upserted.v1delete-событие → Status = Delisted

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

ИнвариантУсловиеГде проверяетсяОшибка
INV-1 VisibilityRuleStatus = Published ⇔ (PropertyObjectStatus != Hidden) ∧ (PropertyObjectOverride.IsVisible != false) ∧ (BuildingOverride.IsVisible != false) ∧ (project-card Status = Published)пересчёт видимости — вызывается каждым консюмером и пересчётом оверрайданет ошибки — детерминированный пересчёт
INV-2 ProjectFirstКарточка публикуется (Published) только при существующей CatalogProjectCard с тем же ProjectId; если проекта ещё нет (out-of-order событий) — карточка создаётся в Hidden и публикуется консюмером проектаconsumer объекта (проверка карточки проекта)нет ошибки — отложенная публикация
INV-3 EffectiveFieldsДля каждого оверрайдимого поля: Effective = Override(поле) ?? SourceSnapshot(поле); Layout.PricePerMeter = round(Price / Layout.AreaTotal, 2)пересчёт эффективных полей при применении оверрайдовнет ошибки — детерминированный пересчёт
INV-4 MonotonicChessboardСобытие chessboard применяется только при event.Version > SourceSnapshot.ChessboardVersionconsumer, до мутациинет — событие идемпотентно пропускается
INV-5 CommissionConsistencyAgentCommission обновляется только при event.RuleVersion >= AgentCommission.RuleVersionconsumer rewardsнет — пропуск
INV-6 PriceRangeЭффективная Price > 0; оверрайд с Price <= 0 отклоняется на записи оверрайдаvalidator оверрайда (см. display-override.md)CATALOG_OVERRIDE_INVALID_PRICE
INV-7 DelistedFrozenИз Delisted нет переходов; консюмеры игнорируют события по Delisted-карточке кроме повторного upsert-а объекта (восстановление в источнике)consumer guardнет

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

СобытиеТипКогдаSnapshot/поля
PropertyObjectCardRecomputedEventдоменноеЛюбой пересчёт эффективных полей/статуса (внутренний триггер обновления агрегатов project-card: Price-диапазон/PropertyObjectsAvailable)Id, ProjectId, Price, Status, PropertyObjectStatus
Карточка не публикует интеграционные события

Витрина — конечная точка данных; просмотры и поисковые действия пишет фронт событиями в user-activity (ADR-0054; user-activity/), статистика застройщика — dev-stats (ADR-0057). Продвижение — контекст promotion; буст наполняет консюмер ../consumers/promotion-boost.md по pin.promotion.purchase-changed.v1 (ADR-0060).

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

АспектЗначение
ИндексыPK pk_property_object_cards (id); btree: ix_property_object_cards_project_id, ix_property_object_cards_building_id, ix_property_object_cards_developer_id, ix_property_object_cards_layout_id; ленточный композит ix_property_object_cards_feed (status, promotion_boost desc, developer_priority desc, popularity_score desc, published_at desc) where status = 'Published'; сортировки: ix_property_object_cards_price (status, price), ix_property_object_cards_published_at (status, published_at desc), ix_property_object_cards_popularity (status, popularity_score desc); фильтры: ix_property_object_cards_type_name, ix_property_object_cards_rooms_count, ix_property_object_cards_availability, ix_property_object_cards_delivery_date
Поискполнотекст — Elasticsearch-проекция property-objects (ADR-0059); в PostgreSQL FTS-колонок/GIN нет
Партиционирование / retentionnone — карточек сотни тысяч максимум (simplicity-first, ADR-0056); Delisted очищаются job-ом через 30 дней
Concurrencyоптимистическая блокировка — консюмеры разных топиков могут писать одну строку; retry по конфликту
Permissions / PIIЧтение — public с гейтом ленты: авторизованный токен или реферальная cookie; для гостя без гейта — 401. Поле AgentCommission сериализуется в ответ только при permission catalog.commissions.view (агент/руководитель агентства; каталог permissions — identity/domain/permission.md). PII в карточке нет
AuditНе требуется (read-проекция); происхождение значений трассируется SourceSnapshot + *SyncedAt

Конкурентность источников и согласованность

Одну строку карточки пишут консюмеры разных топиков и внутренний пересчёт оверрайда; порядок между топиками не гарантирован, поэтому каждый источник обновляет только свои поля под своим guard-ом версии, а конфликт параллельных писателей разрешается оптимистической блокировкой с повтором проигравшего.

ПисательСобытие (топик) / триггерОбновляемые поляGuard монотонностиПоведение при отсутствии/устаревании
chessboardpin.chessboard.property-object-upserted.v1 / pin.chessboard.building-upserted.v1структурные поля, SourceSnapshot, PropertyObjectStatusevent.Version > SourceSnapshot.ChessboardVersion (INV-4)нет события объекта → карточки нет; проекта нет → карточка Hidden до прихода проекта (INV-2)
invest-analyticspin.invest-analytics.property-object-metrics.v1Payback/Reliability/GrowthPercent/Rating/RentalIncomePerYearAnalyticsAsOf (водяной знак)не синхронизировано → метрики null, блок доходности в UI скрыт
rewardspin.rewards.commission-rules.v1AgentCommissionevent.RuleVersion >= AgentCommission.RuleVersion (INV-5)комиссия не задана → VO null, блок комиссии не показывается
catalog (оверрайд)пересчёт при мутации оверрайда (display-override.md)эффективные Title/Price/Badge/Rating/…пересчёт из последнего SourceSnapshot (INV-3)снятие оверрайда → эффективные значения возвращаются к SourceSnapshot
catalog (ранжирование)мутация правила (ranking-rule.md)DeveloperPriorityкаскад INV-5 rankingправило снято → приоритет по каскаду (объект → проект → 0)
promotionpin.promotion.purchase-changed.v1PromotionBoostсумма активных вкладовлаг продвижения → буст устаревает (некритично для «Рекомендованные»)
user-activitypin.user-activity.property-object-view-ended.v1PopularityViews«слепой» инкремент, fail-openзаморожен на Delisted (INV-7); потеря единичного просмотра допустима
Кто побеждает

Писатели не пересекаются по данным (каждый владеет своим набором полей), поэтому конфликта значений нет; при гонке за версию строки проигравший повторяет операцию и переприменяет свой guard (устаревшее событие после повтора идемпотентно пропускается). Любой пересчёт эффективных полей/статуса поднимает PropertyObjectCardRecomputedEvent в том же коммите → обновление поисковой проекции (../search/property-objects.md); окно рассинхронизации витрины и поиска — секунды–минуты (отдаётся признак свежести).

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

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

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