Entity / Aggregate: CatalogProjectCard
Дом полей денормализованной карточки проекта (ЖК/апарт-отеля) публичного каталога (контекст catalog).
Модели ответов public API (лента /hotels, карточка /hotels/[id]/info), payload событий и поисковые
проекции ссылаются сюда и описывают только дельту.
Назначение
CatalogProjectCard — денормализованная витринная проекция проекта застройщика для ленты проектов
(главная страница), поиска, фильтрации, сортировки и ограниченной/полной карточки проекта. Карточка
отвечает на запрос ленты одной строкой без джойнов в источники: название, адрес, девелопер, диапазон
цен, количество объектов, окупаемость.
Структура проекта (декларации, ход строительства, банки, медиа, шахматка) — дом
chessboard/domain/project.md; этот документ владеет только
витринной дельтой: эффективными значениями после корпусных оверрайдов, агрегатами по объектам, метриками
invest-analytics и ранжированием. Полная карточка проекта (вкладки Информация/Локация/Финансирование/
Юридическая информация/Ход строительства/Видео/Карта и шахматка) читается из chessboard; каталог отдаёт
только витринную часть и id для перехода. Полнотекстовый поиск проектов — Elasticsearch-проекция
(../search/projects.md, ADR-0059).
Эффективные значения корпусного уровня: Effective = BuildingOverride(поле главного корпуса) ?? SourceSnapshot(поле); агрегаты (цены/количества/окупаемость) пересчитываются по Published-объектам
(catalog-property-object-card.md).
Классификация
| Поле | Значение |
|---|---|
| Контекст | catalog |
| Kind | Replica (денормализованная read-проекция; мутируется консюмерами + пересчётами) |
| Базовые контракты | реплика-проекция, не агрегат |
| Source of truth | структура проекта — chessboard; имя девелопера — identity (через реплику chessboard); метрики — invest-analytics; эффективные display-значения, агрегаты и ранжирование — этот контекст |
| Таблица | catalog.project_cards |
| Tenant isolation | не tenanted — платформенная витрина (ADR-0052 п.3); Developer.DeveloperId — колонка для фильтров/ranking-scope |
| Search projection | Elasticsearch projects (ADR-0059) |
Полнотекстовый поиск обеспечивает Elasticsearch-документ (PostgreSQL-FTS не используется).
Поля
Легенда источников — как в catalog-property-object-card.md: chessboard /
invest-analytics / override (display-override.md, корпусные) /
identity (через реплику chessboard) / catalog (вычисление).
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | chessboard (Project.Id) | Идентификатор проекта = id в chessboard. |
Developer | DeveloperRefModel (owned) | Да | см. VO; index ix_project_cards_developer_id | chessboard (реплика pin.identity.companies.v1) | Застройщик (id + имя, = CompanyId в identity, ADR-0052); имя индексируется в поисковой проекции. |
DeveloperLogoFileId | Guid? | Нет | без FK (storage/CDN) | identity → chessboard | Логотип застройщика. |
Name | string | Да | varchar(256) | override (ComplexName) → chessboard | Эффективное название комплекса/отеля (BuildingOverride.ComplexName ?? SourceSnapshot.Name); индексируется в поисковой проекции. |
Kind | ProjectKind | Да | хранится как text, varchar(32), index ix_project_cards_kind | chessboard | ResidentialComplex (ЖК) / ApartHotel (апарт-отель) — фильтр «тип» и разведение лент /lots vs /hotels. |
PropertyClass | PropertyClass | Да | хранится как text, varchar(32), index ix_project_cards_class | chessboard | Класс: Comfort / Business / Premium / Elite — фильтр «класс». |
RegionId | long? | Нет | index ix_project_cards_region | chessboard (RegionId события) | Регион — ссылка на справочник handbook, ключ фильтра «регион». null, пока источник не резолвил гео (у Published заполнен — публикация в источнике требует резолва, INV-PRJ-3 chessboard). |
RegionName | string? | Нет (заполнен ⇔ RegionId != null) | varchar(256) | chessboard (RegionSnapshot события, snapshots.md) | Отображаемое имя региона (денормализовано из снапшота события). |
CityId | long? | Нет | index ix_project_cards_city | chessboard (CityId события) | Город — ссылка на справочник handbook, ключ фильтра «город». null, пока источник не резолвил гео (у Published заполнен, INV-PRJ-3 chessboard). |
CityName | string? | Нет (заполнен ⇔ CityId != null) | varchar(256) | chessboard (CitySnapshot события, snapshots.md) | Отображаемое имя города (денормализовано из снапшота события). |
Address | string | Да | varchar(512) | override → chessboard | Адрес; индексируется в поисковой проекции. |
GeoLocation | ProjectGeoLocation (owned) | Нет | см. VO | chessboard | Координаты для карты. |
ConstructionStatus | ConstructionStatus | Да | хранится как text, varchar(32), index ix_project_cards_construction | chessboard | Planned / InProgress / Commissioned — фильтр «статус строительства». |
DeliveryDate | DateOnly? | Нет | — | override → chessboard | Срок сдачи (ближайший по корпусам либо оверрайд). |
ReadinessPercent | short? | Нет | 0..100 | override → chessboard | Готовность строительства. |
Stars | short? | Нет | 1..5 | override → chessboard | Звёздность апарт-отеля. |
DistanceMinutes | int? | Нет | >= 0 | override → chessboard | Минуты до ключевой точки. |
CoverFileId | Guid? | Нет | без FK (storage/CDN) | chessboard | Обложка карточки. |
ShortDescription | string? | Нет | varchar(1024) | chessboard | Краткое описание для ленты/подсказок при наведении. |
Price | NullableRange<decimal> | Нет | Min/Max decimal(14,2), Min > 0, Max >= Min (check); index ix_project_cards_min_price (status, price_min) | catalog (агрегат) | Диапазон цен по Published-объектам проекта (Min=минимум, Max=максимум); null — нет доступных объектов. Диапазон цен ленты + фильтр «ценовой диапазон» + сортировка «по цене». |
PropertyObjectsTotal | int | Да | >= 0, default 0 | catalog (агрегат) | Всего объектов проекта в каталоге (кроме Delisted). |
PropertyObjectsAvailable | int | Да | 0 <= PropertyObjectsAvailable <= PropertyObjectsTotal (check) | catalog (агрегат) | Published-объекты с PropertyObjectStatus = Free — «количество объектов» в ленте. Для гостя точные статусы скрываются на уровне ответа API, не хранения. |
Payback | PropertyObjectPayback (owned, переиспользуется) | Нет | см. catalog-property-object-card.md | catalog (агрегат из invest-analytics) | Лучшая (минимальная в годах) окупаемость по объектам проекта — «окупаемость» в ленте. |
GrowthPercent | decimal(5,2)? | Нет | -99.99..999.99 | invest-analytics | Прогнозный рост цены по проекту, %/год. |
Rating | decimal(3,1)? | Нет | 0.0..10.0 | invest-analytics | Рейтинг проекта. |
DeveloperPriority | int | Да | 0..1000, default 0 | catalog (ranking-rule.md) | Приоритет проекта внутри объектов застройщика. |
PromotionBoost | int | Да | >= 0, default 0 | consumer promotion (../consumers/promotion-boost.md) | Суммарный вес активных пакетов продвижения на проект; наполняется консюмером pin.promotion.purchase-changed.v1 (не владеется каталогом, ADR-0060). Первый ключ сортировки «Рекомендованные» (ranking-rule.md) — ранжирование остаётся у каталога. |
PopularityViews | long | Да | >= 0, default 0 | catalog (consumer pin.user-activity.property-object-view-ended.v1, агрегация по ProjectId) | Счётчик завершённых просмотров объектов проекта. |
PopularityScore | decimal(12,4) | Да | >= 0, default 0, index ix_project_cards_popularity | catalog (job) | Сглаженная популярность (окно 30 дней) — сортировка «по популярности». |
Status | ProjectCardStatus | Да | хранится как text, varchar(32), index ix_project_cards_status | catalog (INV-1) | Published / Hidden / Delisted. |
SourceSnapshot | ProjectSourceSnapshotDocument (jsonb) | Да | typed jsonb, ContractVersion | consumer-ы | Сырые значения chessboard до оверрайдов (см. JSONB). |
PublishedAt | DateTimeOffset? | Нет | index ix_project_cards_published_at | catalog | Первая публикация — сортировка «по дате добавления». |
ChessboardSyncedAt | DateTimeOffset | Да | — | consumer | Применение последнего события chessboard. |
AnalyticsSyncedAt | DateTimeOffset? | Нет | — | consumer | То же для invest-analytics. |
AggregatesRecomputedAt | DateTimeOffset | Да | — | catalog | Последний пересчёт Price/PropertyObjectsTotal/PropertyObjectsAvailable/Payback. |
CreatedAt | DateTimeOffset | Да | — | app | Создание проекции. |
UpdatedAt | DateTimeOffset | Да | — | app | Последний пересчёт. |
Enum ProjectCardStatus
| Значение | Описание |
|---|---|
Published | Проект в ленте/поиске. В ленту попадают только опубликованные в источнике проекты. |
Hidden | Скрыт: chessboard прислал неопубликованный статус проекта либо корпусный оверрайд IsVisible=false скрывает все корпуса. Объекты проекта каскадно Hidden (INV-1 property-object-card). |
Delisted | Проект удалён в источнике; очищается retention-job-ом через 30 дней вместе с объектами. |
Enum ProjectKind
| Значение | Описание |
|---|---|
ResidentialComplex | Жилой комплекс (лента /lots). |
ApartHotel | Апарт-отель (лента /hotels). |
Enum PropertyClass
| Значение | Описание |
|---|---|
Comfort | Комфорт-класс. |
Business | Бизнес-класс. |
Premium | Премиум. |
Elite | Элитный. |
Enum ConstructionStatus
| Значение | Описание |
|---|---|
Planned | Котлован/старт продаж, строительство не начато. |
InProgress | Строится. |
Commissioned | Сдан/введён в эксплуатацию. |
Unknown-значения enum из событий источника маппятся консюмером в ближайшее известное + warning-лог.
Value objects
ProjectGeoLocation (owned)
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
Latitude | decimal(9,6) | Да (внутри VO) | -90.000000..90.000000 | geo_latitude |
Longitude | decimal(9,6) | Да | -180.000000..180.000000 | geo_longitude |
DeveloperRefModel (DeveloperId/DeveloperName) и PropertyObjectPayback (payback_value,
payback_period) переиспользуются из catalog-property-object-card.md.
Price — NullableRange<decimal> с колонками price_min/price_max.
Модели JSONB (versioned)
ProjectSourceSnapshotDocument (jsonb, колонка source_snapshot, ContractVersion = 1)
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|---|---|---|---|
ContractVersion | int | Да | = 1 | v1 |
Name | string | Да | varchar(256); исходное название проекта | v1 |
Address | string | Да | varchar(512) | v1 |
Stars | short? | Нет | 1..5 | v1 |
DeliveryDate | DateOnly? | Нет | ближайшая по корпусам | v1 |
ReadinessPercent | short? | Нет | 0..100 | v1 |
DistanceMinutes | int? | Нет | >= 0 | v1 |
ChessboardVersion | long | Да | >= 1; guard монотонности (INV-3) | v1 |
AnalyticsAsOf | DateTimeOffset? | Нет | момент расчёта метрик проекта | v1 |
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
CatalogPropertyObjectCard (объекты проекта) | reference (обратная, логическая) | property_object_cards.project_id → project_cards.id | пересчёт агрегатов: агрегация по объектам проекта (ProjectId, Status=Published) | скрытие/делистинг проекта каскадно пересчитывает Status объектов |
BuildingDisplayOverride (корпуса проекта) | reference (логическая) | building_display_overrides.project_id → project_cards.id | корпусные оверрайды при пересчёте эффективных полей | удаление оверрайда → пересчёт из SourceSnapshot |
PromotionContribution (вклады продвижения) | reference (логическая, consumer) | TargetKind=Project, TargetId → project_cards.id | пересчёт PromotionBoost = Σ активных вкладов (../consumers/promotion-boost.md) | истечение/отмена (событие promotion) → пересчёт |
chessboard.Project | replica-источник | Id | consumer pin.chessboard.projects.v1 | delete-событие → Delisted |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-1 VisibilityRule | Status = Published ⇔ проект опубликован в источнике ∧ не скрыт оверрайдами всех корпусов | пересчёт видимости в консюмерах и пересчёте оверрайдов | нет — детерминированный пересчёт |
| INV-2 AggregatesConsistency | Price/PropertyObjectsTotal/PropertyObjectsAvailable/Payback соответствуют текущему множеству объектов; пересчёт — в той же транзакции, что изменившая объект операция, по внутреннему событию пересчёта PropertyObjectCardRecomputedEvent | обработчик события пересчёта (агрегация по ProjectId) | нет |
| INV-3 MonotonicChessboard | Событие применяется только при event.Version > SourceSnapshot.ChessboardVersion | consumer | нет — пропуск |
| INV-4 KindImmutable | Kind не меняется после создания (ЖК не становится отелем); расхождение с источником — конфликт в лог + алерт | consumer guard | нет — событие в DLQ при систематическом расхождении |
| INV-5 CascadeHide | Переход Published → Hidden/Delisted каскадно пересчитывает Status всех объектов проекта в той же транзакции | обработчик перехода | нет |
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
ProjectCardRecomputedEvent | доменное | Пересчёт эффективных полей/статуса/агрегатов | Id, Status, Price, PropertyObjectsAvailable |
Интеграционные события карточка не публикует (см. обоснование в catalog-property-object-card.md, раздел «События»).
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK pk_project_cards (id); ленточный композит ix_project_cards_feed (status, promotion_boost desc, developer_priority desc, popularity_score desc, published_at desc) where status = 'Published'; btree: ix_project_cards_developer_id, ix_project_cards_kind, ix_project_cards_class, ix_project_cards_region (region_id), ix_project_cards_city (city_id), ix_project_cards_construction, ix_project_cards_min_price (status, price_min), ix_project_cards_published_at (status, published_at desc), ix_project_cards_popularity (status, popularity_score desc) |
| Поиск | полнотекст — Elasticsearch-проекция projects (ADR-0059); в PostgreSQL FTS-колонок/GIN нет |
| Партиционирование / retention | none — проектов сотни/тысячи; Delisted очищаются через 30 дней |
| Concurrency | оптимистическая блокировка, retry консюмеров при конфликте |
| Permissions / PII | Чтение — public (ограниченная карточка без авторизации разрешена; лента /lots — гейт токен/реф-cookie); для гостя ответ API маскирует точные цены и статусы объектов — правило маскирования живёт в API-методах, не в хранении. PII нет |
| Audit | Не требуется (read-проекция); trace — SourceSnapshot + *SyncedAt |
Ожидания от источников и согласованность
Карточка — реплика: её поля наполняют консюмеры соседних контекстов, поэтому целостность витрины держится на гарантиях версий и порядка, а не на синхронных запросах в чужие контексты.
| Источник | Событие (топик) | Что даёт | Гарантия применения | Поведение при отсутствии/устаревании |
|---|---|---|---|---|
chessboard | pin.chessboard.projects.v1 | структура, статус публикации в источнике, SourceSnapshot, Kind, гео-ссылки + display-имена (RegionId+RegionName, CityId+CityName — из id и снапшотов события, собственной реплики справочника у catalog нет) | монотонность по ChessboardVersion (INV-3): событие с Version <= уже применённого пропускается | нет события проекта → карточки нет (объекты проекта висят Hidden, INV-2 объекта); устаревшее → показываются прежние значения до прихода более нового; переименование региона/города в справочнике доезжает штатной переопубликацией события источником (HR-6 chessboard) |
identity (через реплику chessboard) | pin.identity.companies.v1 | имя/лого застройщика | приходит вместе со снапшотом проекта | переименование застройщика видно после доезда события; до него — прежнее имя |
invest-analytics | pin.invest-analytics.property-object-metrics.v1 | агрегаты GrowthPercent/Rating/Payback (лучшее по объектам) | AnalyticsSyncedAt — водяной знак свежести | не синхронизировано → метрики null, блок в UI скрыт (деградация, не ошибка) |
promotion | pin.promotion.purchase-changed.v1 | PromotionBoost (ADR-0060) | пересчёт суммарного веса активных пакетов консюмером | лаг/недоступность продвижения → буст устаревает, порядок «Рекомендованные» слегка неточен (некритично) |
user-activity | pin.user-activity.property-object-view-ended.v1 | PopularityViews (агрегат по проекту) | «слепой» инкремент, fail-open (../consumers/user-activity-popularity.md) | потеря единичного просмотра допустима; PopularityScore пересчитывает ночной job |
Любое изменение объекта проекта → доменное PropertyObjectCardRecomputedEvent →
пересчёт агрегатов карточки проекта (INV-2) в той же транзакции; в том же коммите поднимается
ProjectCardRecomputedEvent, обновляющий поисковую проекцию (../search/projects.md).
Читатели ленты/поиска в интервале секунды–минуты (лаг консюмеров + индексация) видят прежние значения
цен/агрегатов; отдаётся признак свежести. Критичные для брони проверки статуса делает crm в источнике, а
не витрина.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (4): Entity / Aggregate: CatalogPropertyObjectCard, Entity / Aggregate: DeveloperRankingRule, Entity / Aggregate: Post, Entity / Aggregate: PropertyObjectDisplayOverride + BuildingDisplayOverride
- API (10): Catalog Public API — реестр методов и общие правила, GET /api/admin/v1/buildings/{buildingId}/override — Просмотр оверрайда корпуса (admin), GET /api/v1/catalog/filters — Справочники значений фильтров, GET /api/v1/lots — Лента объектов недвижимости, GET /api/v1/projects — Лента проектов, GET /api/v1/projects/{id} — Карточка проекта: ограниченная / полная, POST /api/admin/v1/projections/rebuild — Принудительный пересчёт карточек по id (admin), POST /api/developers/v1/ranking-rules — Создание правила ранжирования, PUT /api/admin/v1/buildings/{buildingId}/override — Создание/изменение оверрайда корпуса (admin), PUT /api/admin/v1/lots/{propertyObjectId}/override — Создание/изменение оверрайда объекта недвижимости (admin)
- Use Cases (7): UC-CTL-001. Лента проектов (главная страница, бесконечная лента 20–30 карточек), UC-CTL-002. Поиск по названию проекта (FTS: name / address / developer), UC-CTL-003. Фильтрация проектов (тип / класс / регион / город / статус строительства / цена), UC-CTL-004. Сортировка проектов (цена / дата добавления / популярность / окупаемость), UC-CTL-005. Карточка проекта (ограниченная, для гостя), UC-CTL-006. Карточка проекта (полная: шахматка, список объектов, инвест-аналитика), UC-CTL-009. Просмотр шахматки и планировки этажа (переход в chessboard)
- Консюмеры (5): Consumer: Chessboard Projection Sync (pin-catalog-chessboard-sync), Consumer: Invest-Analytics Enrichment (pin-catalog-analytics-enrichment), Consumer: Promotion Boost (pin-catalog-promotion-boost), Consumer: Rewards Commission Enrichment (pin-catalog-rewards-commission), Consumer: User-Activity Popularity (pin-catalog-popularity)
- Фоновые задачи (2): Background Job: DelistedRetentionJob, Background Job: PopularityRecomputeJob
- Поиск (1): ProjectSearchDocument
- adr (1): ADR-0060: Продвижение — отдельный bounded context promotion (полиморфный таргет); catalog и news — консюмеры буста
- index.md (1): Catalog Service (Pin.Catalog)
- registries (1): Реестр сущностей PIN
Связанные документы
- catalog-property-object-card.md (объекты + правило «только дельта»),
display-override.md, ranking-rule.md,
../consumers/promotion-boost.md (
PromotionBoost), ../search/projects.md. - Дома источников:
chessboard/domain/project.md,chessboard/domain/building.md,invest-analytics/,identity/domain/company.md. - Snapshot-модели справочника (
RegionSnapshot/CitySnapshot):handbook/domain/snapshots.md. - ADR-0052, ADR-0056, ADR-0059 (Elasticsearch), ADR-0063 (справочники).