PropertyObjectPriceHistoryFact (ClickHouse)
Append-only факт «изменение цены объекта недвижимости». Питает график роста цены на странице
/lots/[id]/analytics (серия price-per-meter до текущего месяца), API истории цен (грануляция
day/month/year + growthPercent) и портфель инвестора (динамику читает контекст investor-owner через
public API).
Назначение
PropertyObjectPriceHistoryFact хранит каждое изменение цены объекта недвижимости, полученное из контекста
chessboard (событие изменения цены) при обновлении объекта. Бизнес-правило: цены не удаляются и не
редактируются — только append-only вставка; хранилище — ClickHouse. Операционный источник истины
текущей цены — контекст chessboard; этот факт — аналитическая лента изменений, ClickHouse не является
операционным источником истины.
Классификация
| Поле | Значение |
|---|---|
| Контекст | invest-analytics |
| Kind | ClickHouse fact (append-only) |
| Source of truth | текущая цена — chessboard; лента изменений — этот факт |
| Хранилище | ClickHouse, database pin_analytics, table property_object_price_history_facts |
| Tenant isolation | TenantId = DeveloperId (CompanyId застройщика, ADR-0052); чтение — платформенное (цены публичны в каталоге) |
Технические колонки факта: FactId (dedup), SourceEventId (трассировка), TenantId, BrandId?
(в PIN не используется — всегда null), EventTime, IngestionTime.
Table
| Поле | Значение |
|---|---|
| Database | pin_analytics |
| Table | property_object_price_history_facts |
| Engine | ReplacingMergeTree (dedup по ключу сортировки) |
| Owner service | invest-analytics (фоновый консюмер пишет, API читает) |
| Source events | pin.chessboard.property-object-price-changed.v1 (+ разовый backfill из Talent Chessboard API) |
| Transform version | 1 (колонка TransformVersion) |
| Freshness SLA | ≤ 5 минут от события до видимости в API (batch ingestion консюмера, батч ≤ 1000 строк или 5 секунд) |
Columns
| Column | Type | Nullable | Source | Partition/Order | PII |
|---|---|---|---|---|---|
FactId | String | нет | = SourceEventId | order (последний компонент) | нет |
SourceEventId | String | нет | идентификатор входного события; для backfill — backfill:{PropertyObjectId}:{ChangedAt:O} | none | нет |
TenantId | String | нет | DeveloperId из payload события (Guid как строка) | none | нет |
BrandId | Nullable(String) | да | не используется в PIN, всегда NULL | none | нет |
EventTime | DateTime | нет | = ChangedAt (бизнес-время изменения цены) | PARTITION BY toYYYYMM(EventTime), order (предпоследний компонент) | нет |
IngestionTime | DateTime | нет | now() в момент вставки консюмером | none | нет |
PropertyObjectId | UUID | нет | payload события (property_object_id) | order (1-й компонент) | нет |
DeveloperId | UUID | нет | payload события (снапшот измерений в событии, реплика измерений не нужна) | none | нет |
ProjectId | UUID | нет | payload события | none | нет |
BuildingId | Int64 | нет | payload события | none | нет |
LayoutGroupId | Int64 | нет | payload события; связывает с property_object_type_config.GroupId | none | нет |
LayoutId | Int64 | нет | payload события | none | нет |
OldPrice | Nullable(Decimal(18,2)) | да | payload события; NULL для первой зафиксированной цены объекта недвижимости и backfill-строк без предыстории | none | нет |
NewPrice | Decimal(18,2) | нет | payload события; полная цена объекта недвижимости, руб | none | нет |
OldPricePerMeter | Nullable(Decimal(12,2)) | да | payload события; NULL аналогично OldPrice | none | нет |
NewPricePerMeter | Decimal(12,2) | нет | payload события; руб/м²; если в событии нет — консюмер вычисляет NewPrice / AreaTotal (округление до 2 знаков) | none | нет |
AreaTotal | Decimal(10,2) | нет | payload события; общая площадь объекта недвижимости, м² (снапшот на момент изменения) | none | нет |
CurrencyId | Int16 | нет | payload события (CurrencyId — типизированная ссылка справочника валют handbook); RUB-only (ADR-0048), задел под будущую мультивалютность через справочник | none | нет |
CurrencyIsoCode | LowCardinality(String) | нет | CurrencySnapshot.IsoCode из payload (денорм ISO-4217-кода для display; RUB) | none | нет |
ChangeSource | LowCardinality(String) | нет | консюмер: talent-sync (событие chessboard) / backfill (первичная загрузка) | none | нет |
TransformVersion | UInt16 | нет | константа версии маппинга консюмера (сейчас 1) | none | нет |
Персональных данных в факте нет (PII — «нет» во всех колонках): факт описывает объект, не пользователя.
Кросс-сервисный контракт
Ожидания от источника (chessboard, событие pin.chessboard.property-object-price-changed.v1):
измерения объекта недвижимости приходят снапшотом в payload (PropertyObjectId, DeveloperId,
ProjectId, BuildingId, LayoutGroupId, LayoutId, AreaTotal, NewPrice, CurrencyId +
CurrencySnapshot, ChangedAt) — отдельная реплика измерений в invest-analytics не нужна. Валюта —
типизированная ссылка CurrencyId: short + CurrencySnapshot ({ CurrencyId, IsoCode, Name, Sign? });
строкового кода валюты payload отдельным полем не несёт (ISO-код живёт только внутри snapshot). Гарантии источника: at-least-once доставка, ключ
партиционирования по объекту недвижимости (порядок изменений одного объекта сохранён). Допустимые пропуски:
OldPrice/OldPricePerMeter могут отсутствовать (первая зафиксированная цена); NewPricePerMeter может
отсутствовать — консюмер вычисляет NewPrice / AreaTotal. Случай, когда NewPricePerMeter отсутствует и
AreaTotal <= 0 одновременно (цену м² не вычислить), обрабатывается на стороне консюмера
(../consumers/chessboard-price-changed.md).
Гарантии потребителям (история цен через public API, investor-owner): чтение дубль-устойчиво
(argMax(..., IngestionTime), FINAL в online-запросах запрещён); ряд всегда отсортирован по EventTime;
freshness от события до видимости ≤ 5 мин. Устаревание измерений исключено: значения — снапшот на момент
изменения, поздние правки измерений в chessboard не переписывают исторические факты (append-only, цены не
редактируются).
Deduplication
- Dedup key:
FactId = SourceEventId— последний компонентORDER BY; повторная доставка события (at-least-once) даёт строку с идентичным ключом сортировки,ReplacingMergeTreeсхлопывает дубликат при merge. - Replay behavior: идемпотентная вставка — переигрывание топика/повтор батча не порождает дублей после
merge; читающие запросы дубль-устойчивы (агрегации
argMax(NewPricePerMeter, IngestionTime)по периоду),FINALв online-запросах запрещён. - Late event behavior: факт вставляется в партицию своего
EventTimeнезависимо от задержки; порядок внутри объекта недвижимости восстанавливается ключом сортировки (PropertyObjectId, EventTime, FactId), API всегда сортирует поEventTime.
Partition И TTL
- Partition key:
toYYYYMM(EventTime)— график и API режут диапазон дат партициями. - Order key:
(PropertyObjectId, EventTime, FactId)— все запросы фильтруют по конкретному объекту недвижимости (график карточки), затем по диапазону дат;FactIdв хвосте — только для dedup. - TTL/retention: TTL отсутствует — история хранится вечно. Обоснование: (1) график роста цены и
метрика
growthPercentсчитаются за весь жизненный цикл объекта; (2) портфель инвестора (investor-owner) показывает динамику от даты покупки без ограничения давности; (3) объём тривиален для ClickHouse даже на верхней границе ADR-0056: ~10⁶ объектов недвижимости × ~1–4 изменения цены в месяц × 10 лет ≈ 10⁸–5×10⁸ строк узкой таблицы (< 50 ГБ со сжатием). Пересмотр retention — только через ADR. - Rollup-ы/materialized views не создаются (simplicity-first, ADR-0056): грануляция day/month/year
считается прямыми запросами
GROUP BY toStartOfDay/Month/Year(EventTime)по одному объекту недвижимости — объём на объект недвижимости мал (сотни–тысячи строк). Отличие отdev-stats(ADR-0057), где rollup-ы обязательны из-за сканов по иерархии застройщика.
Consumers И Backfill
| Process | Назначение | Checkpoint |
|---|---|---|
Фоновый консюмер chessboard-price-changed (../consumers/chessboard-price-changed.md) | Приём pin.chessboard.property-object-price-changed.v1, маппинг payload → колонки, batch-вставка (≤ 1000 строк / 5 сек) | offset входного топика (подтверждается после успешной вставки батча) |
| Backfill-job первичной загрузки (../jobs/price-history-backfill.md) | Разовая загрузка текущих цен всех объектов недвижимости из Talent Chessboard API при вводе сервиса (ChangeSource = backfill, OldPrice = NULL) | Диапазон обработанных PropertyObjectId в таблице состояния job-а (PostgreSQL) |
Security
- Raw PII не хранится (факт не содержит данных пользователей).
- Чтение — только через public API
invest-analytics(permissioninvest-analytics.price-history.view; гость по реферальной ссылке — readOnly-режим страницы аналитики); прямой доступ к ClickHouse у фронта/ других сервисов отсутствует. - Export permissions проверяются в API layer (PDF-отчёт — ../README.md, отложено).
- ClickHouse не используется как операционный источник истины: текущая цена объекта недвижимости —
chessboard.
Наблюдаемость
- Logs: консюмер логирует размер батча, offset-диапазон (dedup — на стороне CH, число дублей не пишется).
- Metrics:
invest_analytics_price_facts_ingested_total,invest_analytics_price_ingest_lag_seconds(EventTime → IngestionTime),invest_analytics_price_ingest_batch_size. - Traces:
SourceEventId= корреляция со входным событием; сквозная корреляция в API-запросах чтения. - Отказ консюмера — алерт по росту lag; runbook — переигрывание с offset (вставка идемпотентна).
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Расчётные модели: InvestorMonth, AnalyticsSeries, AsideMetrics
- API (5): GET /api/v1/portfolio/properties — Мои объекты (getMyProperties), GET /api/v1/portfolio/summary — Сводка доходности портфеля (getMyPortfolioSummary), GET /api/v1/property-objects/{propertyObjectId}/investor-series — Серии доходности объекта недвижимости, GET /api/v1/property-objects/{propertyObjectId}/price-history — История цены объекта недвижимости (график роста), Investor-Owner API — реестр операций и общие контрактные модели
- Use Cases (3): UC-PRC-001. Просмотр графика роста цены объекта недвижимости (гость / инвестор / реф-гость readOnly), UC-PRC-002. Расчёт доходности объекта недвижимости с выбором субъекта (юр/физ) и налогового режима, UC-PRC-005. Скачивание PDF-отчёта «Скачать расчёт» (контракт; реализация отложена)
- События (1): Event Contract: pin.invest-analytics.property-object-metrics-updated (PropertyObjectMetricsUpdatedKafkaEvent)
- Консюмеры (2): Consumer: ChessboardPropertyObjectPriceChangedConsumer, Talent Price Sync: сведён к consumer-у chessboard-price-changed (решение, не отдельный consumer)
- index.md (2): DevStats Service (Pin.DevStats), InvestAnalytics Service
- jobs (1): Background Job: PriceHistoryBackfillJob
- registries (1): Реестр сущностей PIN
Связанные документы
- Событие-источник:
docs/06-services/chessboard/events/property-object-price-changed.md(контекстchessboard). - Потребление серии в расчёте доходности: investor-series-models.md.
- ADR-0056 (границы применения ClickHouse), ADR-0057 (паттерн facts; здесь rollup-ы не нужны).