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

GET /api/v1/property-objects/{propertyObjectId}/price-history — История цены объекта недвижимости (график роста)

Назначение

Отдаёт ряды точек изменения цены объекта недвижимости из ленты PropertyObjectPriceHistoryFact (ClickHouse) с грануляцией day/month/year и метрикой growthPercent (рост цены м² от первой к последней точке диапазона). Питает график роста цены на странице /lots/[id]/analytics и динамику портфеля investor-owner (docs/06-services/investor-owner/api/get-my-property.md читает этот метод). Метод не зависит от chessboard и PostgreSQL — работает по накопленной ленте ClickHouse.

Metadata

ПолеЗначение
Сервис/контекстinvest-analytics
API areapublic
Feature groupprice-history
Статусdraft
Документdocs/06-services/invest-analytics/api/public/get-property-object-price-history.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (Bearer JWT: инвестор/агент/менеджер) или Anonymous реф-гость (валидная реферальная cookie referralUrl, 30 дней — гейт каталога, docs/06-services/catalog/README.md)
Auth policy / Permissionspolicy CatalogAccess (JWT или реф-cookie); permission invest-analytics.price-history.view (выдана ролям каталога; реф-гость получает её implicit через policy) — каталог permissions: docs/06-services/identity/domain/permissions.md
Scope (RBAC)платформа (цены публичны в каталоге)
Tenant isolationчтение не фильтруется по TenantId факта (TenantId = DeveloperId — только колонка ленты); request не tenanted
Auditnone (public read, PII нет)

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/v1/property-objects/{propertyObjectId}/price-history
Success status200
Idempotency headerне применяется (safe GET)
Correlationсквозной trace через traceparent

Входные данные / параметры запроса

Диапазон дат — единый диапазон NullableRange<DateOnly> (пара from/to — один параметр периода; wire-параметры from/to задают границы Period.From/Period.To).

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
PropertyObjectIdrouteGuidДа!= Guid.Empty
Granularityquery granularityPriceHistoryGranularity (enum)Нетwire day / month / year; default month; неизвестное значение → ValidationError (молчаливый дефолт запрещён)
Periodquery from + query toNullableRange<DateOnly>НетISO yyyy-MM-dd; Period.From <= Period.To; для day при заданных обеих границах диапазон ≤ 731 дня (guard числа точек)

Дефолты Period: Period.To — сегодня (UTC), если не задан. Period.From — если не задан: dayPeriod.To − 365 дней, month/yearnull (вся история от первого факта). Enum PriceHistoryGranularity: Day / Month / Year (wire — строки нижнего регистра).

Модель ответа PropertyObjectPriceHistoryResult

ПолеТипОбязательностьИсточникMaskingОписание
propertyObjectIdGuidДаecho routeОбъект недвижимости.
granularitystring (enum wire)Даecho (после дефолта)Применённая грануляция day/month/year.
fromstring? (ISO date)Нетecho Period.From / дата первой точки, если не задан; null при пустом рядеФактическое начало диапазона.
tostring (ISO date)Даecho Period.To (после дефолта)Конец диапазона.
pointsIReadOnlyList<PricePointModel>Да (может быть пуст)агрегация ленты ценТочки ряда по возрастанию даты; пустой список = истории по объекту недвижимости нет.
growthPercentdecimal?НетвычислениеРост цены м² от первой к последней точке диапазона, %, 1 знак; null, если точек < 2 или первая цена = 0.

PricePointModel

ПолеТипОбязательностьИсточникMaskingОписание
datestring (ISO date)Даначало периода бакета (toStartOfDay/Month/Year(EventTime))Дата точки.
pricedecimalДаargMax(NewPrice, (EventTime, IngestionTime)) внутри бакетаПоследняя полная цена объекта недвижимости в периоде, руб.
pricePerMeterdecimalДаargMax(NewPricePerMeter, (EventTime, IngestionTime)) внутри бакетаПоследняя цена м² в периоде, руб/м².

Алгоритм

  1. Контекст и доступ. Actor через policy CatalogAccess (Bearer JWT или валидная реф-cookie — отсутствие обоих → 401 UNAUTHORIZED); scope — платформа (цены публичны в каталоге).
  2. Проверка входа. PropertyObjectId != Guid.Empty; Granularity ∈ {day,month,year}; Period.From <= Period.To; для day при заданных обеих границах — диапазон ≤ 731 дня → иначе ValidationError (400). Существование объекта недвижимости сознательно не проверяется — метод не обращается к chessboard (деградационная устойчивость): неизвестный/новый PropertyObjectId → пустой points. Допустимость состояния и scope не проверяются (append-only факт без статусов, данные публичны).
  3. Что читаем. Один запрос в ленту цен ClickHouse pin_analytics.property_object_price_history_facts: точки по PropertyObjectId в диапазоне дат (при Period.From = null — без нижней границы), сгруппированные по бакету toStartOfDay|toStartOfMonth|toStartOfYear(EventTime); значение бакета — последняя полная цена argMax(NewPrice, (EventTime, IngestionTime)) и последняя цена м² argMax(NewPricePerMeter, (EventTime, IngestionTime)) (дубль-устойчиво к ReplacingMergeTree, FINAL запрещён), по возрастанию бакета. Партиционный ключ toYYYYMM(EventTime) режет диапазон; PostgreSQL не участвует.
  4. Правила и маппинг. points[k].date ← бакет (ISO); price/pricePerMeter ← argMax-значения. growthPercent: если точек ≥ 2 и первая цена м² > 0 → round1((points[последний].pricePerMeter / points[0].pricePerMeter − 1) × 100); иначе null (guard деления в стиле INV-CALC-1, ../../domain/investor-series-models.md). from ответа ← явный Period.From либо дата первой точки; пустой ряд → from = null, growthPercent = null.
  5. Результат. Ряд точек + growthPercent. Freshness: лента отстаёт от Talent не более чем на SLA консюмера (≤ 5 мин, ../../domain/price-history-fact.md); недоступность ClickHouse → INVEST_ANALYTICS_PRICE_HISTORY_UNAVAILABLE (503).

Диаграмма

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400Невалидные granularity/from/to/диапазон day > 731 дняОшибка валидации запросаno retry
UNAUTHORIZED401Нет JWT и нет валидной реф-cookieТребуется авторизация или реферальная ссылкаno retry
INVEST_ANALYTICS_PRICE_HISTORY_UNAVAILABLE503ClickHouse недоступен / timeout запросаИстория цен временно недоступнаretry с backoff

Пустая история объекта недвижимости — не ошибка: 200 с points = [].

Совместимость и наблюдаемость

  • Новые optional поля ответа — additive (без breaking); удаление/переименование полей, смена wire-значений granularity — breaking (версия метода). Unknown enum на входе → ValidationError (не дефолт).
  • Logs: price-history-read (propertyObjectId, granularity, pointsCount, elapsed; PII нет). Metrics: invest_analytics_price_history_requests_total{granularity}, latency histogram (SLO p95 ≤ 200 мс). Traces: span приёма → span запроса к ленте цен; correlation через сквозной trace traceparent. Dashboard/runbook — борд контекста (ingest lag влияет на свежесть ряда).

Acceptance Criteria

  • Given по объекту недвижимости ≥ 2 фактов, granularity = month, When GET, Then 200 с points по возрастанию даты и growthPercent (1 знак) = рост цены м² от первой к последней точке.
  • Given объект недвижимости неизвестен/новый (истории нет), When GET, Then 200, points = [], from = null, growthPercent = null; chessboard не вызывается (метод устойчив к отсутствию объекта в каталоге).
  • Given granularity = day и обе границы заданы с диапазоном > 731 дня, When GET, Then 400 ValidationError (guard числа точек).
  • Given неизвестное значение granularity, When GET, Then 400 ValidationError (молчаливый дефолт запрещён); from > to400.
  • Given точек < 2 или первая цена м² = 0, When GET, Then growthPercent = null (guard деления).
  • Given ClickHouse недоступен/timeout, When GET, Then 503 INVEST_ANALYTICS_PRICE_HISTORY_UNAVAILABLE (retry с backoff).
  • Given реф-гость с валидной cookie или пользователь с JWT, When GET, Then доступ разрешён (policy CatalogAccess); отсутствие обоих → 401 UNAUTHORIZED.
  • Given цена изменилась в Talent/chessboard, When GET после ингеста, Then новая точка видна с задержкой ≤ freshness ленты (≤ 5 мин).

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

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

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