Consumer: Chessboard Projection Sync (pin-catalog-chessboard-sync)
Consumer group витрины, синхронизирующая проекции catalog (CatalogProjectCard /
CatalogPropertyObjectCard) с событиями контекста chessboard. Один consumer group обрабатывает шесть
типов событий источника; retry/DLQ/replay-политика и version-guard для группы описаны здесь один раз.
Дома полей целевых проекций — catalog-project-card.md и
catalog-property-object-card.md; правило эффективных
значений Effective = Override ?? SourceSnapshot — display-override.md.
Metadata
| Поле | Значение |
|---|---|
| Статус | draft |
| Owning team | PIN Marketplace (контекст catalog) |
| Область действия | Пересчёт денормализованных витринных проекций по событиям источника структуры недвижимости |
Назначение
Consumer group pin-catalog-chessboard-sync потребляет события контекста chessboard (source of truth
структуры недвижимости и статусов продажи) и поддерживает денормализованные карточки витрины:
CatalogProjectCard— upsert/скрытие/делистинг проекта, эффективные поля, каскад видимости на объекты недвижимости;CatalogPropertyObjectCard— upsert паспортных полей объекта недвижимости, статусыРезерв/Бронь/Продан(потокcrm → chessboard → catalog), цены, денормализация проектных/корпусных полей в строку объекта недвижимости (лента/lotsотвечает одной строкой без джойнов).
Витрина — конечная точка данных: собственных интеграционных событий эта группа не публикует
(обоснование — catalog-property-object-card.md, раздел
«События»). Внутренние пересчёты выполняются событиями пересчёта агрегатов
PropertyObjectCardRecomputedEvent / ProjectCardRecomputedEvent в том же согласованном изменении.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | catalog |
| Consumer group | pin-catalog-chessboard-sync |
| Producer | chessboard — контракты подтверждены ТЗ контекста-владельца: chessboard/domain/project.md, chessboard/domain/building.md, chessboard/domain/property-object.md, chessboard/domain/developer.md |
| Топик 1 | pin.chessboard.developer-upserted.v1 — DeveloperUpsertedKafkaEvent (key = Id) |
| Топик 2 | pin.chessboard.project-upserted.v1 — ProjectUpsertedKafkaEvent (key = Id) |
| Топик 3 | pin.chessboard.building-upserted.v1 — BuildingUpsertedKafkaEvent (key = Id) |
| Топик 4 | pin.chessboard.property-object-upserted.v1 — PropertyObjectUpsertedKafkaEvent (key = Id) |
| Топик 5 | pin.chessboard.property-object-status-changed.v1 — PropertyObjectStatusChangedKafkaEvent (key = PropertyObjectId) |
| Топик 6 | pin.chessboard.property-object-price-changed.v1 — PropertyObjectPriceChangedKafkaEvent (key = PropertyObjectId) |
| ContractVersion | ожидается 1 для всех шести событий; при несовпадении версии — warn-and-proceed (метрика catalog_chessboard_sync_contract_mismatch_total) |
| Порядок | key = id агрегата → per-aggregate порядок внутри топика; кросс-топиковый порядок не гарантирован — guard-ы INV-2 «ProjectFirst» и INV-4 «MonotonicChessboard» карточки объекта недвижимости + ограничение LIM-1 ниже |
README каталога и доменные документы используют конвенционные ссылки
pin.chessboard.projects/buildings/property-objects.v1. Владелец подтвердил фактические имена (выше).
Соответствие: projects.v1 → project-upserted.v1; buildings.v1 → building-upserted.v1;
propertyObjects.v1 → property-object-upserted.v1 + property-object-status-changed.v1 + property-object-price-changed.v1.
Событие → эффект в витрине каталога
| Событие | Эффект |
|---|---|
DeveloperUpsertedKafkaEvent | Батч-обновление DeveloperName/DeveloperLogoFileId на всех карточках застройщика |
ProjectUpsertedKafkaEvent | Upsert CatalogProjectCard, статус (INV-1), каскад видимости объектов недвижимости (INV-5), денормализация проектных полей в объекты недвижимости |
BuildingUpsertedKafkaEvent | Корпусные поля объектов недвижимости корпуса (BuildingName/FloorsTotal/DeliveryDate/ReadinessPercent) + проектные агрегаты срока сдачи |
PropertyObjectUpsertedKafkaEvent | Upsert CatalogPropertyObjectCard (паспортные поля, INV-2 ProjectFirst) + пересчёт комиссии агента |
PropertyObjectStatusChangedKafkaEvent | Реплика PropertyObjectStatus (Free/Reserved/Booked/Sold/Hidden), видимость (INV-1), PropertyObjectsAvailable проекта |
PropertyObjectPriceChangedKafkaEvent | SourceSnapshot.Price → эффективная Price, PricePerMeter, MinPrice/MaxPrice проекта |
Контекст события
- Из метаданных события доступны: идентификатор сообщения (
MessageId, дедупликация),CreatedAt, версия контракта, идентификатор и тип агрегата-источника (проект/корпус/объект недвижимости/застройщик). - Актор — системный; карточки не tenanted (платформенная витрина); аудит карточкам не нужен — trace
через
SourceSnapshot+ChessboardSyncedAt. Корреляция трейса — из метаданных события. - Идемпотентность — ключ
catalog-chessboard:{EventType}:{MessageId}; бизнес-идемпотентность — монотонный version-guard (INV-4 property-object-card / INV-3 project-card):event.Version > SourceSnapshot.ChessboardVersion.
Входные данные / payload событий
Контракты — дом сущностей chessboard (разделы «Доменные и интеграционные события»); ниже — только
поля, которые consumers каталога реально читают, и маппинг «поле payload → поле проекции».
DeveloperUpsertedKafkaEvent (pin.chessboard.developer-upserted.v1)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | Guid | Да | Ключ выборки карточек: project_cards.developer_id = Id, property_object_cards.developer_id = Id |
Status | string (enum chessboard) | Да | Не влияет на видимость карточек (видимость проекта решает ProjectUpsertedKafkaEvent); читается только для лога |
Name | string | Да | Батч-перезапись CatalogProjectCard.DeveloperName и CatalogPropertyObjectCard.DeveloperName |
LogoFileId | Guid? | Нет | Батч-перезапись CatalogProjectCard.DeveloperLogoFileId |
Version | long | Да | Логируется; отдельный version-guard не нужен — перезапись идемпотентна, per-key порядок топика гарантирует последнее имя |
ProjectUpsertedKafkaEvent (pin.chessboard.project-upserted.v1)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | Guid | Да | Карточка проекта по Id; при создании → CatalogProjectCard.Id |
TenantId / DeveloperId | Guid | Да | → DeveloperId (равны по INV-PRJ-1 источника; расхождение → DLQ CATALOG_SYNC_TENANT_MISMATCH, fail closed) |
Status | string: Draft/Published/Hidden/Archived | Да | Маппинг статуса карточки: Published → Published (при INV-1), Draft/Hidden → Hidden, Archived → Delisted; unknown → Hidden + warn |
Name | string | Да | → SourceSnapshot.Name; эффективное Name = BuildingOverride.ComplexName ?? SourceSnapshot.Name |
Type | string (enum ProjectType) | Да | → Kind: ResidentialComplex → ResidentialComplex; ApartHotel/Hotel → ApartHotel; Villa → ResidentialComplex + warn; unknown → ResidentialComplex + warn. Guard INV-4 KindImmutable |
Class | string (enum ProjectClass) | Да | → PropertyClass: Comfort/Business/Premium/Elite — 1:1; Standard → Comfort + warn; unknown → Comfort + warn |
ConstructionStatus | string | Да | → ConstructionStatus: Planned → Planned; UnderConstruction → InProgress; Commissioned → Commissioned; Delivered → Commissioned; unknown → InProgress + warn |
CountryId / CountrySnapshot | short / CountrySnapshot | Да | Игнорируются: витрина v1 — проекты РФ, фильтра по стране нет; читаются только для лога |
RegionId | long? | Нет | → RegionId (ключ фильтра «регион»); null (гео источника не резолвлено — неопубликованный проект) → RegionId/RegionName карточки null, карточка выпадает из значений фильтра «регион» |
RegionSnapshot | RegionSnapshot? | Нет (заполнен ⇔ RegionId != null) | → RegionName = RegionSnapshot.Name (дом модели — snapshots.md); RegionId без снапшота / снапшот без id → warn + display-имя null |
CityId | long? | Нет | → CityId (ключ фильтра «город»); null → CityId/CityName карточки null |
CitySnapshot | CitySnapshot? | Нет (заполнен ⇔ CityId != null) | → CityName = CitySnapshot.Name; рассинхрон id ↔ снапшот → warn + display-имя null |
Stars | short? | Нет | → SourceSnapshot.Stars проекта и батчем → SourceSnapshot.Stars объектов недвижимости проекта; эффективное — через корпусный оверрайд |
Price / PropertyObjectsTotalCount / PropertyObjectsFreeCount / PaybackYears / CurrencyId + CurrencySnapshot / HotelierId | — | — | Игнорируются: каталог считает собственные агрегаты по Published-объектам недвижимости с эффективными ценами — INV-2 project-card; валюта проверяется на уровне событий объекта недвижимости (потоки D/F); Payback — из invest-analytics (invest-analytics-enrichment.md) |
CoverImageFileId | Guid? | Нет | → CoverFileId |
DeveloperName | string | Да (DSC-CAT-1) | → DeveloperName проекта + батчем объектов недвижимости |
DeveloperLogoFileId | Guid? | Нет (DSC-CAT-1) | → DeveloperLogoFileId |
AddressLine | string | Да (DSC-CAT-1) | → SourceSnapshot.Address; эффективный Address = BuildingOverride.Address ?? SourceSnapshot.Address; денормализуется в property_object_cards.address |
GeoLatitude / GeoLongitude | decimal? | Нет (DSC-CAT-1) | → owned VO GeoLocation (оба заданы) либо null (оба пусты) |
ShortDescription | string? | Нет (DSC-CAT-1) | → ShortDescription (обрезка до 1024 + warn) |
DistanceMinutes | int? | Нет (DSC-CAT-1) | → SourceSnapshot.DistanceMinutes проекта и объектов недвижимости |
Version | long | Да | Монотонный guard INV-3 project-card → SourceSnapshot.ChessboardVersion |
Специальной обработки не требует: chessboard обновляет свои денормализованные имена и переопубликует
project-upserted с выросшей Version и свежими снапшотами (HR-6,
chessboard/domain/handbook-replica.md) — витрина догоняет
новое имя штатным потоком A. Собственной реплики справочника у catalog нет: значения фильтров
«регион»/«город» строятся distinct-ом по опубликованным карточкам
(get-catalog-filters.md).
BuildingUpsertedKafkaEvent (pin.chessboard.building-upserted.v1)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | Guid | Да | Ключ выборки объектов недвижимости корпуса: property_object_cards.building_id = Id |
ProjectId | Guid | Да | Карточка проекта по ProjectId — пересчёт проектных агрегатов срока сдачи |
Status | string: Draft/Published/Hidden | Да | Читается только для лога: витринная видимость корпуса управляется BuildingDisplayOverride.IsVisible; скрытие корпуса в источнике ожидается каскадом Hidden-статусов его объектов недвижимости (assumption ASM-1 ниже) |
Name | string | Да | Батч-перезапись property_object_cards.building_name |
FloorsTotal | short | Да | Батч-перезапись property_object_cards.floors_total (check Floor <= FloorsTotal — при нарушении строка пропускается + warn: паспорт объекта недвижимости обновит своё PropertyObjectUpsertedKafkaEvent) |
DeliveryYear / DeliveryQuarter | short? / short? | Нет | → DateOnly последнего дня квартала: месяц = DeliveryQuarter * 3, день = последний день месяца; оба null → null. Пишется в SourceSnapshot.DeliveryDate объектов недвижимости корпуса; эффективное DeliveryDate = BuildingOverride.DeliveryDate ?? SourceSnapshot.DeliveryDate |
ReadinessPercent | short? | Нет | → SourceSnapshot.ReadinessPercent объектов недвижимости корпуса; эффективное — через корпусный оверрайд |
ConstructionStatus / SectionsCount / TenantId | — | — | Игнорируются: статус строительства ленты — поле проекта; секции в карточке объекта недвижимости не отображаются |
Version | long | Да | Логируется (у объектов недвижимости guard остаётся по версии объекта недвижимости; корпусные батч-перезаписи идемпотентны, per-key порядок топика достаточен) |
PropertyObjectUpsertedKafkaEvent (pin.chessboard.property-object-upserted.v1)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | Guid | Да | Карточка объекта недвижимости по Id; при создании → CatalogPropertyObjectCard.Id |
TenantId | Guid | Да | Сверка с DeveloperId карточки проекта (fail closed при расхождении) |
ProjectId | Guid | Да | → ProjectId; guard INV-2 ProjectFirst (проверка карточки проекта по ProjectId) |
BuildingId | Guid | Да | → BuildingId |
LayoutId / LayoutGroupId | Guid? | Нет | → LayoutId / LayoutGroupId (ключи матчинга комиссии rewards) |
Status | string (enum PropertyObjectStatus) | Да | → PropertyObjectStatus (реплика 1:1: Free/Reserved/Booked/Sold/Hidden; unknown → Hidden + warn — fail-safe: неизвестный статус не должен продаваться) |
Number | string | Да | → Number |
Floor | short | Да | → Floor |
Section | short? | Нет | Игнорируется (в карточке витрины не отображается — только в шахматке chessboard) |
Rooms | short | Да | → RoomsCount |
TotalArea | decimal | Да | → AreaTotal; пересчёт PricePerMeter = round(Price / AreaTotal, 2) |
LivingArea | decimal? | Нет | → AreaLiving |
Price | decimal | Да | → SourceSnapshot.Price; эффективная Price = PropertyObjectOverride.Price ?? SourceSnapshot.Price; Price <= 0 в источнике при отсутствии оверрайда → карточка в Hidden + warn (витрина не показывает нулевые цены, INV-6 property-object-card) |
CurrencyId + CurrencySnapshot | short + CurrencySnapshot | Да | Валидация CurrencySnapshot.IsoCode = RUB (v1 витрины — рублёвая, ADR-0048; дом снапшота — snapshots.md); иное → DLQ CATALOG_SYNC_UNSUPPORTED_CURRENCY + алерт; в карточку валюта не пишется |
PricePerMeter | decimal | Да | Игнорируется — каталог пересчитывает по эффективной цене |
WindowViews.Views | string[] (enum WindowViewKind) | Да (может быть пуст) | → View: первый элемент списка по словарю Sea→«Море», City→«Город», Courtyard→«Двор», Mountains→«Горы», Park→«Парк», River→«Река», Street→«Улица»; пустой список/unknown → null |
Capacity | short? | Нет | Игнорируется (поле карточки не предусмотрено доменом v1) |
PropertyType | string? | Нет (DSC-CAT-2) | → TypeName: Apartment→«Квартира» (для Kind=ApartHotel проекта — «Апартамент»), Parking→«Паркинг», Commercial→«Коммерция»; отсутствует/unknown → fallback по Kind проекта (ResidentialComplex→«Квартира», ApartHotel→«Апартамент») + warn |
Version | long | Да | Монотонный guard INV-4 → SourceSnapshot.ChessboardVersion |
SourceSnapshot.TitleИсходный заголовок карточки в chessboard не существует — генерируется каталогом детерминированно:
Rooms = 0 → «Студия, {TotalArea} м²»; иначе → «{Rooms}-комн. {toLower(TypeName)}, {TotalArea} м²»
(площадь — инвариантная культура, 2 знака). Эффективный Title = PropertyObjectOverride.Title ?? SourceSnapshot.Title (INV-3 property-object-card).
PropertyObjectStatusChangedKafkaEvent (pin.chessboard.property-object-status-changed.v1)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
PropertyObjectId | Guid | Да | Карточка объекта недвижимости по PropertyObjectId; нет карточки → retriable CATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUND (out-of-order против property-object-upserted) |
ToStatus | string (enum PropertyObjectStatus) | Да | → PropertyObjectStatus; пересчёт видимости (INV-1: Hidden источника скрывает карточку) |
FromStatus / StatusSource / HoldDealId / SoldAt / StatusChangedAt | — | — | Только лог/диагностика (таймлайн статусов — ответственность chessboard/crm) |
ReservedUntil / BookedUntil | DateTimeOffset? | Нет | Игнорируются: карточка каталога таймеры не хранит; обратный отсчёт в UI шахматки читается из chessboard |
TenantId / ProjectId / BuildingId | Guid | Да | Сверка с карточкой (fail closed) / контекст лога |
Version | long | Да | Монотонный guard INV-4 |
PropertyObjectPriceChangedKafkaEvent (pin.chessboard.property-object-price-changed.v1)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
PropertyObjectId | Guid | Да | Карточка объекта недвижимости по PropertyObjectId; нет карточки → retriable CATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUND |
NewPrice | decimal | Да | → SourceSnapshot.Price; эффективная Price = PropertyObjectOverride.Price ?? NewPrice; PricePerMeter = round(Price / AreaTotal, 2) |
OldPrice / ChangedAt / Source | — | — | Только лог (история цен — контекст invest-analytics) |
CurrencyId + CurrencySnapshot / PricePerMeter | — | — | Как в property-object-upserted: валидация CurrencySnapshot.IsoCode = RUB / пересчёт по эффективной цене |
TenantId / ProjectId | Guid | Да | Сверка/контекст |
Version | long | Да | Монотонный guard INV-4 |
Алгоритм
Отдельный проход для каждого типа события. Общие для всех шаги — приём (шаг 1) и согласованная фиксация/результат (шаги 6–7) — идентичны потоку A и в B–F описываются дельтой.
A. ProjectUpserted (основной поток проекта)
- Контекст события. Восстановить метаданные события
ProjectUpserted; сверить версию контракта c1(несовпадение — warn-and-proceed); проверить идемпотентность по ключуcatalog-chessboard:project-upserted:{MessageId}— дубль → логskipped-duplicate, ack. - Проверка входа.
Id != Guid.Empty;DeveloperId != Guid.EmptyиTenantId == DeveloperId(иначеCATALOG_SYNC_TENANT_MISMATCH— non-retriable → DLQ, fail closed);Nameнепустое (≤ 256, truncate + warn);AddressLineнепустое (≤ 512, truncate + warn; отсутствие поля →CATALOG_SYNC_CONTRACT_FIELD_MISSINGnon-retriable → DLQ + алерт, DSC-CAT-1);DeveloperNameнепустое (та же ветка DSC-CAT-1);Version >= 1; маппинги enumStatus/Type/Class/ConstructionStatus— по таблицам payload (unknown → fallback + warn, событие не теряется); гео-блок — по таблице payload:RegionId/CityId+ display-имена изRegionSnapshot/CitySnapshot(null-гео допустимо — неопубликованный проект; рассинхрон id ↔ снапшот → warn + display-имяnull). Существование по id не проверяется — операция upsert. - Что читаем. Карточка проекта по
Id(может отсутствовать — первое событие проекта). При обновлении дополнительно — живые корпусные оверрайды проекта (для эффективных полей). - Бизнес-правила и переходы.
- Version-guard (INV-3 project-card): карточка есть и
Version <= SourceSnapshot.ChessboardVersion→ метрикаcatalog_chessboard_sync_stale_total, логskipped-stale, к согласованной фиксации (только отметка идемпотентности), ack. - Kind-guard (INV-4 project-card): маппинг
Type≠Kind→ warn + метрикаcatalog_chessboard_sync_conflicts_total; систематическое расхождение (3-й раз подряд по одному id) →CATALOG_SYNC_KIND_CHANGED(non-retriable → DLQ + алерт). - Создание (карточки нет): маппинг поле-в-поле по таблице payload;
SourceSnapshot(ContractVersion = 1,Name,Address = AddressLine,Stars,DeliveryDate = null(заполнит поток C),ReadinessPercent = null,DistanceMinutes,ChessboardVersion = Version,AnalyticsAsOf = null); эффективные поля =SourceSnapshot(оверрайдов при создании нет); агрегатыMinPrice/MaxPrice = null,PropertyObjectsTotal/PropertyObjectsAvailable = 0,DeveloperPriority = 0,PromotionBoost = 0,PopularityViews/Score = 0;Statusпо маппингу; приStatus = Published→PublishedAt= время события;ChessboardSyncedAt= время события. - Обновление: перезапись snapshot-полей; применение оверрайдов (эффективные
Name/Address/Stars/DeliveryDate/ReadinessPercent/DistanceMinutes=Override ?? SourceSnapshot) и пересчёт видимости (INV-1). Поля каталога (DeveloperPriority,PromotionBoost,Popularity*, агрегаты) не трогаются. - Переход статуса: маппинг
Status→ProjectCardStatus; переходы — строго по stateDiagram project-card (в т.ч.Delisted → Publishedпри восстановлении); первый переход вPublishedустанавливаетPublishedAt(еслиnull).
- Version-guard (INV-3 project-card): карточка есть и
- Что меняется.
- Создание или обновление карточки проекта.
- Денормализация в объекты недвижимости (только при фактическом изменении соответствующих
значений): батч по объектам недвижимости проекта (кроме
Delisted) —Address/DeveloperName/DeveloperId+ snapshotStars/DistanceMinutes; эффективныеStars/DistanceMinutesпересчитываются с учётом корпусных оверрайдов, партиями по 500 строк. - Каскад видимости (INV-5 project-card):
Published → Hidden— объекты недвижимостиPublished → Hidden;* → Delisted— то же вDelisted;Hidden → Published— пересчёт INV-1 по каждому объекту недвижимости (партии по 500:Published, еслиPropertyObjectStatus != Hidden∧ нет живогоPropertyObjectOverride.IsVisible = false∧ нет живогоBuildingOverride.IsVisible = false),PublishedAtобъекта недвижимости — при первомPublished.
- Согласованность. Внутреннее событие пересчёта агрегатов
ProjectCardRecomputedEvent(Id,Status,MinPrice,MaxPrice,PropertyObjectsAvailable) — точка расширения и триггер консистентности агрегатов. Карточка проекта + батчи объектов недвижимости + отметка идемпотентности — в одном согласованном изменении; offset подтверждается только после успешной фиксации. Конфликт оптимистической блокировки с конкурирующим консюмером/оверрайдом → перечитать и повторить (до 3 раз), далее — retriable fail. - Результат. Успех → ack; retriable fail → retry по политике; non-retriable → DLQ + алерт. Метрики/логи — раздел «Наблюдаемость».
B. DeveloperUpserted
- Приём. Ключ
catalog-chessboard:developer-upserted:{MessageId}; дубль → skip. - Проверка.
Id != Guid.Empty;Nameнепустое (≤ 256, truncate + warn);Version >= 1. - Что читаем. Агрегаты не загружаются: если карточек застройщика нет → лог
no-cards(не ошибка: проекты застройщика ещё не публиковались), к согласованной фиксации. - Правила. Version-guard не применяется (перезапись имени идемпотентна; порядок per-key гарантирует последнее значение).
- Что меняется. Батч-перезапись
DeveloperName/DeveloperLogoFileIdкарточек проектов иDeveloperNameкарточек объектов недвижимости застройщика (без загрузки сущностей). - Согласованность. Батчи + отметка идемпотентности — в одном согласованном изменении.
- Результат. Как A7.
C. BuildingUpserted
- Приём. Ключ
catalog-chessboard:building-upserted:{MessageId}. - Проверка.
Id != Guid.Empty;ProjectId != Guid.Empty;Nameнепустое (≤ 256); `FloorsTotal= 1
;DeliveryQuarter∈1..4(если задан);ReadinessPercent∈0..100;Version >= 1`. - Что читаем. Карточка проекта по
ProjectId— нет →CATALOG_SYNC_PROJECT_NOT_FOUNDretriable (out-of-order противproject-upserted); живой корпусной оверрайд; наличие объектов недвижимости корпуса (нет → обновлять нечего, только идемпотентная отметка). - Правила. Вычислить
sourceDeliveryDate(последний день квартала — таблица payload) и целевые эффективные значения:effDelivery = buildingOverride?.DeliveryDate ?? sourceDeliveryDate,effReadiness = buildingOverride?.ReadinessPercent ?? payload.ReadinessPercent. - Что меняется.
- Батч по объектам недвижимости корпуса (кроме
Delisted):BuildingName,FloorsTotal,DeliveryDate = effDelivery,ReadinessPercent = effReadiness,SourceSnapshot.DeliveryDate = sourceDeliveryDate,SourceSnapshot.ReadinessPercent = payload.ReadinessPercent(партии по 500; строки сFloor > FloorsTotalпропускаются + warn — паспорт исправитproperty-object-upserted). - Проектные агрегаты срока:
SourceSnapshot.DeliveryDateпроекта = минимум по объектам недвижимости проекта (Status != Delisted, значение не null);SourceSnapshot.ReadinessPercentпроекта = минимум тем же запросом (консервативная оценка — принятое правило каталога); эффективныеDeliveryDate/ReadinessPercentпроекта — через корпусные оверрайды.
- Батч по объектам недвижимости корпуса (кроме
- Согласованность. Внутреннее событие
ProjectCardRecomputedEvent; всё — в одном согласованном изменении, как A6. - Результат. Как A7.
D. PropertyObjectUpserted (основной поток объекта недвижимости)
- Приём. Ключ
catalog-chessboard:property-object-upserted:{MessageId}. - Проверка.
Id/ProjectId/BuildingId != Guid.Empty;Numberнепустой (≤ 32);Floor >= -5;Rooms∈0..20;TotalArea > 0;LivingAreanull или ∈(0, TotalArea];Price >= 0;CurrencySnapshot.IsoCode == "RUB"(иначеCATALOG_SYNC_UNSUPPORTED_CURRENCY— non-retriable → DLQ + алерт);Version >= 1; маппингStatus— unknown →Hidden+ warn. INV-2 ProjectFirst: карточка проекта поProjectId; нет → карточка объекта недвижимости создаётся/обновляется вHidden(отложенная публикация; поток A опубликует каскадом), логproject-missing. СверкаTenantId != DeveloperIdкарточки проекта →CATALOG_SYNC_TENANT_MISMATCH(DLQ, fail closed). - Что читаем. Карточка объекта недвижимости по
Id(может отсутствовать); живой оверрайд объекта недвижимости и живой корпусной оверрайд. - Правила и переходы.
- Version-guard INV-4: карточка есть и
Version <= SourceSnapshot.ChessboardVersion→ skip-stale (метрика + отметка идемпотентности, ack; остаточный риск — LIM-1). - INV-7 DelistedFrozen:
Status == Delisted— событие upsert обрабатывается (восстановление объекта недвижимости в источнике; переходDelisted → Published/Hiddenпо INV-1). - Создание: маппинг по таблице payload; проектные денормализации из карточки проекта (
Address,DeveloperId,DeveloperName,Stars,DistanceMinutes— из его эффективных значений и snapshot);SourceSnapshot(ContractVersion = 1;Title— сгенерированный, правило выше;Price;Badge = null;Stars/DistanceMinutes— из snapshot проекта;DeliveryDate/ReadinessPercent— null до события корпуса; метрики — null до событияinvest-analytics;ChessboardVersion = Version);DeveloperPriority— по активным правилам ранжирования (дом ranking-rule.md), иначе 0;PromotionBoost— сумма весов активных вкладов продвижения из репликиPromotionContribution(консюмер promotion-boost.md), иначе 0;Popularity* = 0. - Обновление: перезапись snapshot-полей, применение оверрайдов (INV-3:
Title/Price/Badge/Stars/DeliveryDate/ReadinessPercent/DistanceMinutes/Payback/Reliability/Rating) и пересчёт видимости (INV-1);PricePerMeter = round(эффективная Price / AreaTotal, 2). - Комиссия агента: при создании и при изменении полей матчинга (
LayoutId/LayoutGroupId/Rooms/TotalArea/PropertyType) — пересчётAgentCommissionпо правилу матчинга (дом — rewards-commission.md, раздел «Матчинг»); недоступность internal APIrewardsне валит событие:AgentCommissionостаётся прежней/null+ метрикаcatalog_commission_recompute_deferred_total(обновит ближайшее событиеrewards). ChessboardSyncedAt= время события; первыйPublished→PublishedAt.
- Version-guard INV-4: карточка есть и
- Что меняется. Создание или обновление карточки объекта недвижимости.
- Согласованность. Внутреннее событие
PropertyObjectCardRecomputedEvent(Id,ProjectId,Price,Status,PropertyObjectStatus) в том же согласованном изменении пересчитывает агрегаты проектаMinPrice/MaxPrice/PropertyObjectsTotal/PropertyObjectsAvailable/Paybackпо объектам недвижимости проекта (INV-2 project-card) и обновляетAggregatesRecomputedAt. Объект недвижимости + проектные агрегаты + отметка идемпотентности — атомарно, как A6. - Результат. Как A7.
E. PropertyObjectStatusChanged
- Приём. Ключ
catalog-chessboard:property-object-status-changed:{MessageId}. - Проверка.
PropertyObjectId != Guid.Empty;Version >= 1; маппингToStatus(unknown →Hidden- warn). Карточка объекта недвижимости; нет →
CATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUND— retriable (out-of-order противproperty-object-upserted).
- warn). Карточка объекта недвижимости; нет →
- Что читаем. Карточка объекта недвижимости; для пересчёта видимости — живые оверрайды объекта недвижимости и корпуса; карточка проекта (её отсутствие — инфраструктурная аномалия → retriable).
- Правила. Version-guard INV-4 (stale → skip). INV-7:
Status == Delisted→ skip + warn (кроме восстановления — оно приходитproperty-object-upserted). ЗатемPropertyObjectStatus= реплика; пересчёт видимости —ToStatus = Hidden→ карточкаHidden; выход изHidden→Publishedпри INV-1. - Что меняется. Обновление карточки объекта недвижимости.
- Согласованность. Внутреннее событие
PropertyObjectCardRecomputedEvent→ пересчётPropertyObjectsAvailable(иMinPrice/MaxPrice/Payback— статус влияет на множествоPublished+Free) проекта в том же согласованном изменении, как A6. - Результат. Как A7. SLA критичный поток: «Резерв/Бронь/Продан» должны попасть в витрину < 1 мин (README каталога, alert на лаг топика 5).
F. PropertyObjectPriceChanged
- Приём. Ключ
catalog-chessboard:property-object-price-changed:{MessageId}. - Проверка.
PropertyObjectId != Guid.Empty;NewPrice >= 0;CurrencySnapshot.IsoCode == "RUB"(иначе DLQ);Version >= 1; карточка объекта недвижимости — нет → retriableCATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUND. - Что читаем. Карточка объекта недвижимости + живой оверрайд объекта недвижимости.
- Правила. Version-guard INV-4; INV-7 Delisted → skip.
SourceSnapshot.Price = NewPrice; эффективнаяPrice = propertyObjectOverride?.Price ?? NewPrice;NewPrice <= 0при отсутствии оверрайда →Hidden+ warn (INV-6);PricePerMeter = round(Price / AreaTotal, 2);ChessboardSyncedAt= время события. - Что меняется. Обновление карточки объекта недвижимости.
- Согласованность. Внутреннее событие
PropertyObjectCardRecomputedEvent→MinPrice/MaxPriceпроекта в том же согласованном изменении, как A6. - Результат. Как A7.
Диаграмма
Идемпотентность и replay
- Дедупликация: ключ
catalog-chessboard:{eventType}:{MessageId}— повторная доставка after-commit-crash не мутирует состояние. - Бизнес-guard: монотонный version-guard
payload.Version > SourceSnapshot.ChessboardVersion(INV-4 property-object-card / INV-3 project-card) — защита от out-of-order и replay; батч-перезаписи (B, C) идемпотентны по построению. - Replay: да. Полный re-read всех шести топиков с offset 0 безопасен (upsert + version-guard +
идемпотентные батчи). Runbook расхождений: сверка
SourceSnapshot.ChessboardVersionс версией агрегата источника; точечный пересчёт — admin-операцияPOST /api/admin/v1/projections/rebuild.
Retry и DLQ
DLQ-топик группы: pin.catalog.chessboard-sync-dlq.v1 (исходное событие + заголовки x-dlq-reason,
x-dlq-error-code, x-source-topic, x-original-offset).
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook catalog-overview: восстановить зависимость |
| Карточка проекта не найдена (out-of-order building→project) | CATALOG_SYNC_PROJECT_NOT_FOUND | 6 попыток, backoff 30s→1m→2m→4m→8m→16m | Да, после исчерпания | Проверить прохождение project-upserted; replay из DLQ |
| Карточка объекта недвижимости не найдена (status/price раньше upsert) | CATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUND | та же политика | Да | То же для property-object-upserted |
| Отсутствует обязательное поле контракта (DSC-CAT-1) | CATALOG_SYNC_CONTRACT_FIELD_MISSING | Нет (non-retriable) | Да + алерт | Согласовать additive-расширение с chessboard → replay |
| Валюта ≠ RUB | CATALOG_SYNC_UNSUPPORTED_CURRENCY | Нет | Да + алерт | Решение о мультивалютности витрины (ADR) |
TenantId ≠ DeveloperId карточки | CATALOG_SYNC_TENANT_MISMATCH | Нет | Да + алерт | Ручной разбор с chessboard (fail closed) |
Систематическая смена Kind (INV-4 project-card) | CATALOG_SYNC_KIND_CHANGED | Нет | Да + алерт | Ручной разбор данных источника |
| Конфликт оптимистической блокировки — гонка консюмеров/оверрайда | — | 3 немедленных повтора с перечиткой | После исчерпания | Расследование гонки |
Наблюдаемость и безопасность
- Logs (structured):
consumed/skipped-duplicate/skipped-stale/created/updated/status-cascaded/project-missing/dead-letteredс полямиEventId,AggregateId,payload.Version,SourceSnapshot.ChessboardVersion,affectedPropertyObjects(размер батча). PII нет. - Metrics:
catalog_chessboard_sync_lag_seconds(по топикам),..._consume_duration_ms,..._stale_total,..._retries_total,..._dlq_total,..._conflicts_total,..._contract_mismatch_total,catalog_commission_recompute_deferred_total. - Traces: span приёма события → span обработки → spans хранилища; correlation из метаданных.
- Alerts: лаг
pin.chessboard.property-object-status-changed.v1> 5 мин (статусы брони — README каталога); DLQ > 0; рост conflicts; deferred-комиссии > 100/час. - Dashboard / Runbook: catalog-overview; runbooks «replay DLQ», «rebuild проекций по id», «сверка карточек с chessboard».
- Безопасность: системный актор; входная граница — trusted (внутренний Kafka, m2m ACL топиков); tenant mismatch → fail closed; событий с PII нет; маскирование неприменимо.
Acceptance Criteria
- Создание карточки проекта. Дано
ProjectUpserted(Status=Published,Version=1) для новогоId. Когда consumer обрабатывает. Тогда создаётсяCatalogProjectCardвPublished,PublishedAt= время события, эффективные поля = snapshot, агрегаты =0/null,ChessboardVersion = 1; ack. - Out-of-order: объект раньше проекта. Дано
PropertyObjectUpsertedпришёл раньшеProjectUpserted. Когда обрабатывается объект. Тогда карточка объекта создаётся вHidden(INV-2 ProjectFirst, логproject-missing); последующийProjectUpsertedкаскадом публикует объект (INV-5). - Out-of-order: статус раньше upsert. Дано
PropertyObjectStatusChangedбез карточки объекта. Когда обрабатывается. ТогдаCATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUND(retriable), 6 попыток backoff, затем DLQ. - Stale (version-guard). Дано событие с
Version <= SourceSnapshot.ChessboardVersion. Когда обрабатывается. Тогдаskipped-stale(метрика), состояние карточки не меняется, ack. - Дубль (
MessageId). Дано повтор с тем жеMessageId. Когда доставлен. Тогдаskipped-duplicate, no-op, ack. - Смена статуса брони (SLA). Дано
PropertyObjectStatusChanged(ToStatus=Booked). Когда обрабатывается. ТогдаPropertyObjectStatus=Booked,PropertyObjectsAvailableпроекта пересчитан; изменение попадает в витрину < 1 мин. - Валюта ≠ RUB. Дано событие объекта недвижимости с
CurrencySnapshot.IsoCode = USD. Когда обрабатывается. ТогдаCATALOG_SYNC_UNSUPPORTED_CURRENCY→ DLQ + алерт (non-retriable). - Гео проекта. Дано
ProjectUpsertedсRegionId/RegionSnapshotиCityId/CitySnapshot. Когда обрабатывается. Тогда карточка получаетRegionId/CityIdиRegionName/CityNameиз снапшотов; событие сRegionId = null(гео не резолвлено) пишетnull-гео без ошибки. - Переименование региона. Дано регион переименован в справочнике и
chessboardпереопубликовалproject-upsertedс выросшейVersionи новымRegionSnapshot.Name(HR-6). Когда обрабатывается. ТогдаRegionNameкарточки обновляется штатным потоком A; специальной обработки нет. - Tenant mismatch. Дано
TenantId != DeveloperIdкарточки. Когда обрабатывается. ТогдаCATALOG_SYNC_TENANT_MISMATCH→ DLQ (fail closed). - Отсутствует поле контракта (DSC-CAT-1). Дано событие без
AddressLine/DeveloperName. Когда обрабатывается. ТогдаCATALOG_SYNC_CONTRACT_FIELD_MISSING→ DLQ + алерт. - Систематическая смена
Kind. Дано маппингTypeтретий раз подряд расходится сKindкарточки. Когда обрабатывается. ТогдаCATALOG_SYNC_KIND_CHANGED→ DLQ + алерт (первые два раза — warn + conflicts-метрика). - Деградация комиссии. Дано
rewardsinternal API недоступен при создании/изменении полей матчинга. Когда пересчитываетсяAgentCommission. Тогда событие не падает: комиссия остаётсяnull/прежней +catalog_commission_recompute_deferred_total. - Каскад видимости. Дано проект
Published → Hidden. Когда обрабатывается. Тогда объекты недвижимости проекта →Hidden(INV-5); обратный переход публикует по INV-1. - Нулевая цена. Дано
Price <= 0без оверрайда цены. Когда обрабатывается. Тогда карточка объекта →Hidden+ warn (INV-6 property-object-card — витрина не показывает нулевые цены). - Replay. Дано полный re-read всех шести топиков с offset
0. Когда переигрывается. Тогда итоговое состояние идентично (upsert + version-guard + идемпотентные батч-перезаписи).
Расхождения контрактов и допущения (реестр)
| # | Расхождение / допущение | Статус | Действие |
|---|---|---|---|
| DSC-CAT-1 | ProjectUpsertedKafkaEvent (дом chessboard/domain/project.md) не перечисляет в snapshot поля DeveloperName, DeveloperLogoFileId, AddressLine, GeoLatitude/GeoLongitude, ShortDescription, DistanceMinutes, которые витрина обязана отдавать одной строкой (гео-блок регион/город закрыт: id + снапшоты в контракте) | открыто | Согласовать additive-расширение payload (без bump ContractVersion); до расширения события без AddressLine/DeveloperName уходят в DLQ (CATALOG_SYNC_CONTRACT_FIELD_MISSING) |
| DSC-CAT-2 | У PropertyObjectUpsertedKafkaEvent нет типа объекта (PropertyType), а карточке нужен TypeName и матчингу комиссий rewards — PropertyTypes | открыто | Additive-поле PropertyType в событие объекта недвижимости; действующий fallback — по Kind проекта (+warn) |
| DSC-CAT-3 | chessboard v1 не публикует события удаления объекта недвижимости/корпуса (только soft-delete в источнике) — Delisted карточки объекта недвижимости достижим только каскадом от Archived-проекта | открыто | Согласовать additive-флаг IsDeleted в PropertyObjectUpsertedKafkaEvent либо событие property-object-deleted; runbook rebuild — компенсация |
| LIM-1 | Кросс-топиковый порядок property-object-upserted vs property-object-status-changed/property-object-price-changed не гарантирован при едином монотонном guard-е: событие с меньшей Version из другого топика может быть пропущено после применения большей | принято (ADR-0056) | Метрика skipped-stale + алерт на рост; компенсация — следующее событие объекта недвижимости либо admin rebuild |
| ASM-1 | Скрытие корпуса в источнике (Building.Status = Hidden) отражается в витрине каскадом Hidden-статусов объектов недвижимости от chessboard, а не полем корпуса в каталоге | допущение | Подтвердить у владельца chessboard; PIN-скрытие корпуса — BuildingDisplayOverride.IsVisible |
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): POST /api/admin/v1/projections/rebuild — Принудительный пересчёт карточек по id (admin)
- События (2): Event Contract: pin.chessboard.developer-upserted (DeveloperUpsertedKafkaEvent), Event Contract: pin.chessboard.property-object-price-changed (PropertyObjectPriceChangedKafkaEvent)
- Консюмеры (3): Consumer: Invest-Analytics Enrichment (pin-catalog-analytics-enrichment), Consumer: Rewards Commission Enrichment (pin-catalog-rewards-commission), Consumer: User-Activity Popularity (pin-catalog-popularity)
- index.md (1): Catalog Service (Pin.Catalog)
Связанные документы
- Целевые проекции: catalog-project-card.md,
catalog-property-object-card.md; оверрайды —
display-override.md; ранжирование/продвижение при создании карточки —
ranking-rule.md, promotion-boost.md (
PromotionBoost). - Дома источника:
chessboard/domain/project.md·chessboard/domain/building.md·chessboard/domain/property-object.md·chessboard/domain/developer.md·chessboard/domain/reference-data.md(enum-маппинги) ·handbook/domain/snapshots.md(snapshot-модели гео/валюты) ·chessboard/domain/handbook-replica.md(refresh имён HR-6). - Соседние консюмеры каталога: invest-analytics-enrichment.md, rewards-commission.md.
- ADR-0052, ADR-0056.