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

UC-PRC-001. Просмотр графика роста цены объекта недвижимости (гость / инвестор / реф-гость readOnly)

Назначение

Пользователь на странице /lots/[id]/analytics смотрит историю изменения цены объекта недвижимости. Система по грануляции day|month|year отдаёт ряд точек цены + growthPercent (рост от первой к последней точке диапазона), собранный из append-only фактов PropertyObjectPriceHistoryFact. Доступно инвестору/агенту по токену и гостю по реферальной ссылке (readOnly). Контекст invest-analytics.

Metadata

ПолеЗначение
IDUC-PRC-001
Контекстinvest-analytics
Типосновной
Статусdraft

Актор

ПолеЗначение
РольИнвестор / Агент / Менеджер ПИН (по токену) или Гость по реферальной ссылке (реф-cookie, readOnly-режим страницы)
Permissions / scopeinvest-analytics.price-history.view (public, выдаётся ролям каталога; реф-гость проходит тот же гейт, что и /lots — токен или валидная реф-cookie 30 дней)
Tenantне применяется: чтение платформенное, цены публичны в каталоге; TenantId факта (= DeveloperId) при чтении не фильтруется
Record-levelне применяется — данные объекта, не пользователя; PII в факте нет

Предусловие

  • Consumer chessboard-price-changed принимает pin.chessboard.property-object-price-changed.v1 и наполняет факты цен (batch-вставка, идемпотентно по FactId = SourceEventId); первичный backfill из Talent Chessboard выполнен (../jobs/price-history-backfill.md).
  • ClickHouse доступен; таблица property_object_price_history_facts создана (ReplacingMergeTree, PARTITION toYYYYMM(EventTime), ORDER (PropertyObjectId, EventTime, FactId), без TTL).
  • Пользователь прошёл гейт каталога: авторизован (JWT) или имеет валидную реф-cookie.

Триггер

ПолеЗначение
ИсточникUI (открытие вкладки) + API call
Триггерящий элементСтраница /lots/[id]/analytics, блок «График роста»; переключатель грануляции день/месяц/год повторяет запрос с новым granularity

Основной Поток

  1. Пользователь открывает карточку объекта недвижимости и переходит на вкладку /lots/[id]/analytics. Для реф-гостя страница отрисовывается в readOnly-режиме (скрыты кнопки действий), но график и переключатели грануляции доступны — просмотр не привилегирован.

  2. Фронт вызывает GET property-objects/{propertyObjectId}/price-history?granularity=month (дефолт month; from/to не переданы — берётся полный диапазон «от первого факта до сейчас»). Заголовок — Bearer-токен либо реф-cookie.

  3. Система проверяет вход:

    • PropertyObjectId не пустой;
    • Granularity ∈ {Day, Month, Year} (wire day|month|year); неизвестное значение — ошибка ValidationError (HTTP 400), молчаливый дефолт запрещён;
    • from/to необязательны и независимы: только from → «от даты до сейчас»; только to → «от первого факта до to»; ни одного → полный диапазон «от первого факта до сейчас»; проверка From <= To применяется только когда заданы оба — при нарушении INVEST_ANALYTICS_INVALID_RANGE;
    • To − From ≤ 20 лет — защита от вырожденного диапазона (для day-грануляции).

    Существование объекта недвижимости в chessboard не проверяется (принятое решение: запрос по несуществующему PropertyObjectId возвращает пустой ряд — дополнительный m2m-вызов на каждый график не оправдан, simplicity-first ADR-0056).

  4. Система читает факты цен по объекту недвижимости за диапазон (partition pruning по toYYYYMM(EventTime), ключ сортировки по PropertyObjectId) и агрегирует их по бакетам:

    • bucket = toStartOfDay|toStartOfMonth|toStartOfYear(EventTime) по Granularity;
    • в каждом бакете — «последняя цена периода»: Price = argMax(NewPrice, (EventTime, IngestionTime)), PricePerMeter = argMax(NewPricePerMeter, (EventTime, IngestionTime)); argMax по паре с IngestionTime делает чтение дубль-устойчивым до фонового merge ReplacingMergeTree (FINAL запрещён);
    • сортировка бакетов по возрастанию. Ожидаемый объём — сотни строк на объект недвижимости (rollup-ов нет осознанно).
  5. Система строит ответ:

    • каждый бакет → PricePoint { Time = bucket, Price, PricePerMeter };
    • growthPercent: при points.Count >= 2 && points.First().PricePerMeter > 0round((points.Last().PricePerMeter / points.First().PricePerMeter − 1) × 1000) / 10 (процент с 1 знаком, по цене м² — сопоставимо между объектами разной площади); иначе (0 или 1 точка, нулевая первая цена) → growthPercent = 0. Результат: { propertyObjectId, granularity, points: [{time, price, pricePerMeter}], growthPercent } (camelCase); пустой ряд — валидный ответ 200 (points: [], growthPercent: 0).
  6. Фронт рисует график роста; бейдж growthPercent показывается рядом с заголовком; переключение грануляции повторяет шаги 2–5 с новым granularity.

Альтернативные Потоки

A1. По объекту недвижимости нет фактов (новый объект / несуществующий id)

  1. ClickHouse возвращает 0 строк → points = [], growthPercent = 0, HTTP 200.
  2. Фронт показывает состояние «история цены пока не накоплена» (пустой график); ошибкой не считается. Прогнозная часть графика страницы строится серией price-per-meter из UC-PRC-002 независимо.
  1. Ни JWT, ни валидной реф-cookie → 401; фронт открывает auth-модалку (гейт каталога, как у /lots). Возврата в основной поток нет до прохождения гейта.

A3. Невалидные параметры

  1. granularity вне day|month|year или from > to → 400 (ValidationError / INVEST_ANALYTICS_INVALID_RANGE); фронт не должен порождать такие запросы — ошибка защищает от ручных вызовов API.

A4. ClickHouse недоступен

  1. ClickHouse недоступен → 503 INVEST_ANALYTICS_PRICE_HISTORY_UNAVAILABLE; алерт (ошибки 5xx public API); расчёт доходности (UC-PRC-002) продолжает работать — его историческая часть уходит в synthetic-fallback.
  2. Фронт показывает плейсхолдер графика с retry.

A5. Лаг консюмера (свежее изменение цены ещё не видно)

  1. Изменение цены в Talent произошло < 5 мин назад (Freshness SLA факта) — точка может отсутствовать; это допустимо по контракту (freshness ≤ 5 мин, batch ≤ 1000 строк / 5 сек). Никаких действий — следующий запрос увидит точку.

A6. Диапазон задан, но фактов в нём нет (при наличии фактов вне диапазона)

  1. from/to попали в период без изменений цены (например, узкое окно между изменениями) → points = [], growthPercent = 0, HTTP 200 — как A1, но по срезу диапазона. Ошибкой не считается.
  2. Фронт показывает пустой график для выбранного окна; расширение диапазона или иная грануляция вернут точки. growthPercent при 0/1 точке = 0 (нечего сравнивать).

Постусловие

  • Состояние системы не изменилось (read-only): ни PostgreSQL, ни ClickHouse, ни outbox не мутированы.
  • Пользователь видит ряд точек цены выбранной грануляции и growthPercent за диапазон.

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
ValidationError400granularity вне enum, PropertyObjectId пуст, диапазон > 20 летИсправить параметры запроса
INVEST_ANALYTICS_INVALID_RANGE400from > toИсправить диапазон
Unauthorized401Нет ни JWT, ни валидной реф-cookieОткрыть auth-модалку (гейт каталога)
INVEST_ANALYTICS_PRICE_HISTORY_UNAVAILABLE503ClickHouse недоступен (A4)Плейсхолдер графика + retry с backoff

События

Сценарий (read-only) событий не публикует. Данные готовит consumed-событие pin.chessboard.property-object-price-changed.v1 — batch ingestion фактов (../consumers/chessboard-price-changed.md; контракт события — docs/06-services/chessboard/events/property-object-price-changed.md).

Acceptance Criteria

#КритерийПроверка
AC1granularity=day/month/year возвращают ряды с бакетами toStartOfDay/Month/Year и последней ценой периодаинтеграционный тест на CH-фикстуре с несколькими фактами внутри одного бакета
AC2growthPercent = рост PricePerMeter от первой к последней точке диапазона, 1 знак; при < 2 точек = 0unit-тест формулы + тест пустого ряда
AC3Повторная доставка события цены (дубль до merge) не искажает значения бакета (argMax по IngestionTime, без FINAL)тест с дублированной вставкой факта
AC4Реф-гость без токена получает график (реф-cookie проходит гейт); без cookie — 401e2e-тест двух режимов гейта
AC5Пустой ряд по несуществующему объекту недвижимости — 200, не 404API-тест
AC6Недоступный ClickHouse → 503 с кодом, без падения процессатест с отключённым CH
AC7Частичный диапазон (from без to и наоборот) трактуется как открытый; заданный диапазон без фактов → 200 пустой ряд, growthPercent = 0API-тесты открытого и пустого (по срезу) диапазона

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

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

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