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

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 ?? SourceSnapshotdisplay-override.md.

Metadata

ПолеЗначение
Статусdraft
Owning teamPIN 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 grouppin-catalog-chessboard-sync
Producerchessboard — контракты подтверждены ТЗ контекста-владельца: chessboard/domain/project.md, chessboard/domain/building.md, chessboard/domain/property-object.md, chessboard/domain/developer.md
Топик 1pin.chessboard.developer-upserted.v1DeveloperUpsertedKafkaEvent (key = Id)
Топик 2pin.chessboard.project-upserted.v1ProjectUpsertedKafkaEvent (key = Id)
Топик 3pin.chessboard.building-upserted.v1BuildingUpsertedKafkaEvent (key = Id)
Топик 4pin.chessboard.property-object-upserted.v1PropertyObjectUpsertedKafkaEvent (key = Id)
Топик 5pin.chessboard.property-object-status-changed.v1PropertyObjectStatusChangedKafkaEvent (key = PropertyObjectId)
Топик 6pin.chessboard.property-object-price-changed.v1PropertyObjectPriceChangedKafkaEvent (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 на всех карточках застройщика
ProjectUpsertedKafkaEventUpsert CatalogProjectCard, статус (INV-1), каскад видимости объектов недвижимости (INV-5), денормализация проектных полей в объекты недвижимости
BuildingUpsertedKafkaEventКорпусные поля объектов недвижимости корпуса (BuildingName/FloorsTotal/DeliveryDate/ReadinessPercent) + проектные агрегаты срока сдачи
PropertyObjectUpsertedKafkaEventUpsert CatalogPropertyObjectCard (паспортные поля, INV-2 ProjectFirst) + пересчёт комиссии агента
PropertyObjectStatusChangedKafkaEventРеплика PropertyObjectStatus (Free/Reserved/Booked/Sold/Hidden), видимость (INV-1), PropertyObjectsAvailable проекта
PropertyObjectPriceChangedKafkaEventSourceSnapshot.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ТипОбязательностьКак используется (маппинг)
IdGuidДаКлюч выборки карточек: project_cards.developer_id = Id, property_object_cards.developer_id = Id
Statusstring (enum chessboard)ДаНе влияет на видимость карточек (видимость проекта решает ProjectUpsertedKafkaEvent); читается только для лога
NamestringДаБатч-перезапись CatalogProjectCard.DeveloperName и CatalogPropertyObjectCard.DeveloperName
LogoFileIdGuid?НетБатч-перезапись CatalogProjectCard.DeveloperLogoFileId
VersionlongДаЛогируется; отдельный version-guard не нужен — перезапись идемпотентна, per-key порядок топика гарантирует последнее имя

ProjectUpsertedKafkaEvent (pin.chessboard.project-upserted.v1)

Поле payloadТипОбязательностьКак используется (маппинг)
IdGuidДаКарточка проекта по Id; при создании → CatalogProjectCard.Id
TenantId / DeveloperIdGuidДаDeveloperId (равны по INV-PRJ-1 источника; расхождение → DLQ CATALOG_SYNC_TENANT_MISMATCH, fail closed)
Statusstring: Draft/Published/Hidden/ArchivedДаМаппинг статуса карточки: Published → Published (при INV-1), Draft/Hidden → Hidden, Archived → Delisted; unknown → Hidden + warn
NamestringДаSourceSnapshot.Name; эффективное Name = BuildingOverride.ComplexName ?? SourceSnapshot.Name
Typestring (enum ProjectType)ДаKind: ResidentialComplex → ResidentialComplex; ApartHotel/Hotel → ApartHotel; Villa → ResidentialComplex + warn; unknown → ResidentialComplex + warn. Guard INV-4 KindImmutable
Classstring (enum ProjectClass)ДаPropertyClass: Comfort/Business/Premium/Elite — 1:1; Standard → Comfort + warn; unknown → Comfort + warn
ConstructionStatusstringДаConstructionStatus: Planned → Planned; UnderConstruction → InProgress; Commissioned → Commissioned; Delivered → Commissioned; unknown → InProgress + warn
CountryId / CountrySnapshotshort / CountrySnapshotДаИгнорируются: витрина v1 — проекты РФ, фильтра по стране нет; читаются только для лога
RegionIdlong?НетRegionId (ключ фильтра «регион»); null (гео источника не резолвлено — неопубликованный проект) → RegionId/RegionName карточки null, карточка выпадает из значений фильтра «регион»
RegionSnapshotRegionSnapshot?Нет (заполнен ⇔ RegionId != null)RegionName = RegionSnapshot.Name (дом модели — snapshots.md); RegionId без снапшота / снапшот без id → warn + display-имя null
CityIdlong?НетCityId (ключ фильтра «город»); nullCityId/CityName карточки null
CitySnapshotCitySnapshot?Нет (заполнен ⇔ CityId != null)CityName = CitySnapshot.Name; рассинхрон id ↔ снапшот → warn + display-имя null
Starsshort?Нет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)
CoverImageFileIdGuid?НетCoverFileId
DeveloperNamestringДа (DSC-CAT-1)DeveloperName проекта + батчем объектов недвижимости
DeveloperLogoFileIdGuid?Нет (DSC-CAT-1)DeveloperLogoFileId
AddressLinestringДа (DSC-CAT-1)SourceSnapshot.Address; эффективный Address = BuildingOverride.Address ?? SourceSnapshot.Address; денормализуется в property_object_cards.address
GeoLatitude / GeoLongitudedecimal?Нет (DSC-CAT-1)→ owned VO GeoLocation (оба заданы) либо null (оба пусты)
ShortDescriptionstring?Нет (DSC-CAT-1)ShortDescription (обрезка до 1024 + warn)
DistanceMinutesint?Нет (DSC-CAT-1)SourceSnapshot.DistanceMinutes проекта и объектов недвижимости
VersionlongДаМонотонный 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ТипОбязательностьКак используется (маппинг)
IdGuidДаКлюч выборки объектов недвижимости корпуса: property_object_cards.building_id = Id
ProjectIdGuidДаКарточка проекта по ProjectId — пересчёт проектных агрегатов срока сдачи
Statusstring: Draft/Published/HiddenДаЧитается только для лога: витринная видимость корпуса управляется BuildingDisplayOverride.IsVisible; скрытие корпуса в источнике ожидается каскадом Hidden-статусов его объектов недвижимости (assumption ASM-1 ниже)
NamestringДаБатч-перезапись property_object_cards.building_name
FloorsTotalshortДаБатч-перезапись property_object_cards.floors_total (check Floor <= FloorsTotal — при нарушении строка пропускается + warn: паспорт объекта недвижимости обновит своё PropertyObjectUpsertedKafkaEvent)
DeliveryYear / DeliveryQuartershort? / short?НетDateOnly последнего дня квартала: месяц = DeliveryQuarter * 3, день = последний день месяца; оба null → null. Пишется в SourceSnapshot.DeliveryDate объектов недвижимости корпуса; эффективное DeliveryDate = BuildingOverride.DeliveryDate ?? SourceSnapshot.DeliveryDate
ReadinessPercentshort?НетSourceSnapshot.ReadinessPercent объектов недвижимости корпуса; эффективное — через корпусный оверрайд
ConstructionStatus / SectionsCount / TenantIdИгнорируются: статус строительства ленты — поле проекта; секции в карточке объекта недвижимости не отображаются
VersionlongДаЛогируется (у объектов недвижимости guard остаётся по версии объекта недвижимости; корпусные батч-перезаписи идемпотентны, per-key порядок топика достаточен)

PropertyObjectUpsertedKafkaEvent (pin.chessboard.property-object-upserted.v1)

Поле payloadТипОбязательностьКак используется (маппинг)
IdGuidДаКарточка объекта недвижимости по Id; при создании → CatalogPropertyObjectCard.Id
TenantIdGuidДаСверка с DeveloperId карточки проекта (fail closed при расхождении)
ProjectIdGuidДаProjectId; guard INV-2 ProjectFirst (проверка карточки проекта по ProjectId)
BuildingIdGuidДаBuildingId
LayoutId / LayoutGroupIdGuid?НетLayoutId / LayoutGroupId (ключи матчинга комиссии rewards)
Statusstring (enum PropertyObjectStatus)ДаPropertyObjectStatus (реплика 1:1: Free/Reserved/Booked/Sold/Hidden; unknown → Hidden + warn — fail-safe: неизвестный статус не должен продаваться)
NumberstringДаNumber
FloorshortДаFloor
Sectionshort?НетИгнорируется (в карточке витрины не отображается — только в шахматке chessboard)
RoomsshortДаRoomsCount
TotalAreadecimalДаAreaTotal; пересчёт PricePerMeter = round(Price / AreaTotal, 2)
LivingAreadecimal?НетAreaLiving
PricedecimalДаSourceSnapshot.Price; эффективная Price = PropertyObjectOverride.Price ?? SourceSnapshot.Price; Price <= 0 в источнике при отсутствии оверрайда → карточка в Hidden + warn (витрина не показывает нулевые цены, INV-6 property-object-card)
CurrencyId + CurrencySnapshotshort + CurrencySnapshotДаВалидация CurrencySnapshot.IsoCode = RUB (v1 витрины — рублёвая, ADR-0048; дом снапшота — snapshots.md); иное → DLQ CATALOG_SYNC_UNSUPPORTED_CURRENCY + алерт; в карточку валюта не пишется
PricePerMeterdecimalДаИгнорируется — каталог пересчитывает по эффективной цене
WindowViews.Viewsstring[] (enum WindowViewKind)Да (может быть пуст)View: первый элемент списка по словарю Sea→«Море», City→«Город», Courtyard→«Двор», Mountains→«Горы», Park→«Парк», River→«Река», Street→«Улица»; пустой список/unknown → null
Capacityshort?НетИгнорируется (поле карточки не предусмотрено доменом v1)
PropertyTypestring?Нет (DSC-CAT-2)TypeName: Apartment→«Квартира» (для Kind=ApartHotel проекта — «Апартамент»), Parking→«Паркинг», Commercial→«Коммерция»; отсутствует/unknown → fallback по Kind проекта (ResidentialComplex→«Квартира», ApartHotel→«Апартамент») + warn
VersionlongДаМонотонный 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ТипОбязательностьКак используется (маппинг)
PropertyObjectIdGuidДаКарточка объекта недвижимости по PropertyObjectId; нет карточки → retriable CATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUND (out-of-order против property-object-upserted)
ToStatusstring (enum PropertyObjectStatus)ДаPropertyObjectStatus; пересчёт видимости (INV-1: Hidden источника скрывает карточку)
FromStatus / StatusSource / HoldDealId / SoldAt / StatusChangedAtТолько лог/диагностика (таймлайн статусов — ответственность chessboard/crm)
ReservedUntil / BookedUntilDateTimeOffset?НетИгнорируются: карточка каталога таймеры не хранит; обратный отсчёт в UI шахматки читается из chessboard
TenantId / ProjectId / BuildingIdGuidДаСверка с карточкой (fail closed) / контекст лога
VersionlongДаМонотонный guard INV-4

PropertyObjectPriceChangedKafkaEvent (pin.chessboard.property-object-price-changed.v1)

Поле payloadТипОбязательностьКак используется (маппинг)
PropertyObjectIdGuidДаКарточка объекта недвижимости по PropertyObjectId; нет карточки → retriable CATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUND
NewPricedecimalДа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 / ProjectIdGuidДаСверка/контекст
VersionlongДаМонотонный guard INV-4

Алгоритм

Отдельный проход для каждого типа события. Общие для всех шаги — приём (шаг 1) и согласованная фиксация/результат (шаги 6–7) — идентичны потоку A и в B–F описываются дельтой.

A. ProjectUpserted (основной поток проекта)

  1. Контекст события. Восстановить метаданные события ProjectUpserted; сверить версию контракта c 1 (несовпадение — warn-and-proceed); проверить идемпотентность по ключу catalog-chessboard:project-upserted:{MessageId} — дубль → лог skipped-duplicate, ack.
  2. Проверка входа. 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_MISSING non-retriable → DLQ + алерт, DSC-CAT-1); DeveloperName непустое (та же ветка DSC-CAT-1); Version >= 1; маппинги enum Status/Type/Class/ ConstructionStatus — по таблицам payload (unknown → fallback + warn, событие не теряется); гео-блок — по таблице payload: RegionId/CityId + display-имена из RegionSnapshot/CitySnapshot (null-гео допустимо — неопубликованный проект; рассинхрон id ↔ снапшот → warn + display-имя null). Существование по id не проверяется — операция upsert.
  3. Что читаем. Карточка проекта по Id (может отсутствовать — первое событие проекта). При обновлении дополнительно — живые корпусные оверрайды проекта (для эффективных полей).
  4. Бизнес-правила и переходы.
    • Version-guard (INV-3 project-card): карточка есть и Version <= SourceSnapshot.ChessboardVersion → метрика catalog_chessboard_sync_stale_total, лог skipped-stale, к согласованной фиксации (только отметка идемпотентности), ack.
    • Kind-guard (INV-4 project-card): маппинг TypeKind → 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 = PublishedPublishedAt = время события; ChessboardSyncedAt = время события.
    • Обновление: перезапись snapshot-полей; применение оверрайдов (эффективные Name/Address/ Stars/DeliveryDate/ReadinessPercent/DistanceMinutes = Override ?? SourceSnapshot) и пересчёт видимости (INV-1). Поля каталога (DeveloperPriority, PromotionBoost, Popularity*, агрегаты) не трогаются.
    • Переход статуса: маппинг StatusProjectCardStatus; переходы — строго по stateDiagram project-card (в т.ч. Delisted → Published при восстановлении); первый переход в Published устанавливает PublishedAt (если null).
  5. Что меняется.
    • Создание или обновление карточки проекта.
    • Денормализация в объекты недвижимости (только при фактическом изменении соответствующих значений): батч по объектам недвижимости проекта (кроме Delisted) — Address/DeveloperName/ DeveloperId + snapshot Stars/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.
  6. Согласованность. Внутреннее событие пересчёта агрегатов ProjectCardRecomputedEvent (Id, Status, MinPrice, MaxPrice, PropertyObjectsAvailable) — точка расширения и триггер консистентности агрегатов. Карточка проекта + батчи объектов недвижимости + отметка идемпотентности — в одном согласованном изменении; offset подтверждается только после успешной фиксации. Конфликт оптимистической блокировки с конкурирующим консюмером/оверрайдом → перечитать и повторить (до 3 раз), далее — retriable fail.
  7. Результат. Успех → ack; retriable fail → retry по политике; non-retriable → DLQ + алерт. Метрики/логи — раздел «Наблюдаемость».

B. DeveloperUpserted

  1. Приём. Ключ catalog-chessboard:developer-upserted:{MessageId}; дубль → skip.
  2. Проверка. Id != Guid.Empty; Name непустое (≤ 256, truncate + warn); Version >= 1.
  3. Что читаем. Агрегаты не загружаются: если карточек застройщика нет → лог no-cards (не ошибка: проекты застройщика ещё не публиковались), к согласованной фиксации.
  4. Правила. Version-guard не применяется (перезапись имени идемпотентна; порядок per-key гарантирует последнее значение).
  5. Что меняется. Батч-перезапись DeveloperName/DeveloperLogoFileId карточек проектов и DeveloperName карточек объектов недвижимости застройщика (без загрузки сущностей).
  6. Согласованность. Батчи + отметка идемпотентности — в одном согласованном изменении.
  7. Результат. Как A7.

C. BuildingUpserted

  1. Приём. Ключ catalog-chessboard:building-upserted:{MessageId}.
  2. Проверка. Id != Guid.Empty; ProjectId != Guid.Empty; Name непустое (≤ 256); `FloorsTotal

    = 1; DeliveryQuarter1..4(если задан);ReadinessPercent0..100; Version >= 1`.

  3. Что читаем. Карточка проекта по ProjectId — нет → CATALOG_SYNC_PROJECT_NOT_FOUND retriable (out-of-order против project-upserted); живой корпусной оверрайд; наличие объектов недвижимости корпуса (нет → обновлять нечего, только идемпотентная отметка).
  4. Правила. Вычислить sourceDeliveryDate (последний день квартала — таблица payload) и целевые эффективные значения: effDelivery = buildingOverride?.DeliveryDate ?? sourceDeliveryDate, effReadiness = buildingOverride?.ReadinessPercent ?? payload.ReadinessPercent.
  5. Что меняется.
    • Батч по объектам недвижимости корпуса (кроме 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 проекта — через корпусные оверрайды.
  6. Согласованность. Внутреннее событие ProjectCardRecomputedEvent; всё — в одном согласованном изменении, как A6.
  7. Результат. Как A7.

D. PropertyObjectUpserted (основной поток объекта недвижимости)

  1. Приём. Ключ catalog-chessboard:property-object-upserted:{MessageId}.
  2. Проверка. Id/ProjectId/BuildingId != Guid.Empty; Number непустой (≤ 32); Floor >= -5; Rooms0..20; TotalArea > 0; LivingArea null или ∈ (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).
  3. Что читаем. Карточка объекта недвижимости по Id (может отсутствовать); живой оверрайд объекта недвижимости и живой корпусной оверрайд.
  4. Правила и переходы.
    • 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 API rewards не валит событие: AgentCommission остаётся прежней/null + метрика catalog_commission_recompute_deferred_total (обновит ближайшее событие rewards).
    • ChessboardSyncedAt = время события; первый PublishedPublishedAt.
  5. Что меняется. Создание или обновление карточки объекта недвижимости.
  6. Согласованность. Внутреннее событие PropertyObjectCardRecomputedEvent (Id, ProjectId, Price, Status, PropertyObjectStatus) в том же согласованном изменении пересчитывает агрегаты проекта MinPrice/MaxPrice/PropertyObjectsTotal/PropertyObjectsAvailable/ Payback по объектам недвижимости проекта (INV-2 project-card) и обновляет AggregatesRecomputedAt. Объект недвижимости + проектные агрегаты + отметка идемпотентности — атомарно, как A6.
  7. Результат. Как A7.

E. PropertyObjectStatusChanged

  1. Приём. Ключ catalog-chessboard:property-object-status-changed:{MessageId}.
  2. Проверка. PropertyObjectId != Guid.Empty; Version >= 1; маппинг ToStatus (unknown → Hidden
    • warn). Карточка объекта недвижимости; нет → CATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUNDretriable (out-of-order против property-object-upserted).
  3. Что читаем. Карточка объекта недвижимости; для пересчёта видимости — живые оверрайды объекта недвижимости и корпуса; карточка проекта (её отсутствие — инфраструктурная аномалия → retriable).
  4. Правила. Version-guard INV-4 (stale → skip). INV-7: Status == Delisted → skip + warn (кроме восстановления — оно приходит property-object-upserted). Затем PropertyObjectStatus = реплика; пересчёт видимости — ToStatus = Hidden → карточка Hidden; выход из HiddenPublished при INV-1.
  5. Что меняется. Обновление карточки объекта недвижимости.
  6. Согласованность. Внутреннее событие PropertyObjectCardRecomputedEvent → пересчёт PropertyObjectsAvailableMinPrice/MaxPrice/Payback — статус влияет на множество Published+Free) проекта в том же согласованном изменении, как A6.
  7. Результат. Как A7. SLA критичный поток: «Резерв/Бронь/Продан» должны попасть в витрину < 1 мин (README каталога, alert на лаг топика 5).

F. PropertyObjectPriceChanged

  1. Приём. Ключ catalog-chessboard:property-object-price-changed:{MessageId}.
  2. Проверка. PropertyObjectId != Guid.Empty; NewPrice >= 0; CurrencySnapshot.IsoCode == "RUB" (иначе DLQ); Version >= 1; карточка объекта недвижимости — нет → retriable CATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUND.
  3. Что читаем. Карточка объекта недвижимости + живой оверрайд объекта недвижимости.
  4. Правила. 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 = время события.
  5. Что меняется. Обновление карточки объекта недвижимости.
  6. Согласованность. Внутреннее событие PropertyObjectCardRecomputedEventMinPrice/MaxPrice проекта в том же согласованном изменении, как A6.
  7. Результат. Как 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).

СбойКодRetryDLQРучное действие
Инфраструктурный (PostgreSQL/Kafka недоступны, timeout)Бесконечный exponential backoff 1s→60s с паузой партицииНетRunbook catalog-overview: восстановить зависимость
Карточка проекта не найдена (out-of-order building→project)CATALOG_SYNC_PROJECT_NOT_FOUND6 попыток, 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
Валюта ≠ RUBCATALOG_SYNC_UNSUPPORTED_CURRENCYНетДа + алертРешение о мультивалютности витрины (ADR)
TenantIdDeveloperId карточки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-метрика).
  • Деградация комиссии. Дано rewards internal 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-1ProjectUpsertedKafkaEvent (дом 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 и матчингу комиссий rewardsPropertyTypesоткрытоAdditive-поле PropertyType в событие объекта недвижимости; действующий fallback — по Kind проекта (+warn)
DSC-CAT-3chessboard 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

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

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

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