UC-PRC-001. Просмотр графика роста цены объекта недвижимости (гость / инвестор / реф-гость readOnly)
Назначение
Пользователь на странице /lots/[id]/analytics смотрит историю изменения цены объекта недвижимости.
Система по грануляции day|month|year отдаёт ряд точек цены + growthPercent (рост от первой к последней
точке диапазона), собранный из append-only фактов PropertyObjectPriceHistoryFact.
Доступно инвестору/агенту по токену и гостю по реферальной ссылке (readOnly). Контекст invest-analytics.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-PRC-001 |
| Контекст | invest-analytics |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | Инвестор / Агент / Менеджер ПИН (по токену) или Гость по реферальной ссылке (реф-cookie, readOnly-режим страницы) |
| Permissions / scope | invest-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, PARTITIONtoYYYYMM(EventTime), ORDER(PropertyObjectId, EventTime, FactId), без TTL). - Пользователь прошёл гейт каталога: авторизован (JWT) или имеет валидную реф-cookie.
Триггер
| Поле | Значение |
|---|---|
| Источник | UI (открытие вкладки) + API call |
| Триггерящий элемент | Страница /lots/[id]/analytics, блок «График роста»; переключатель грануляции день/месяц/год повторяет запрос с новым granularity |
Основной Поток
-
Пользователь открывает карточку объекта недвижимости и переходит на вкладку
/lots/[id]/analytics. Для реф-гостя страница отрисовывается в readOnly-режиме (скрыты кнопки действий), но график и переключатели грануляции доступны — просмотр не привилегирован. -
Фронт вызывает
GET property-objects/{propertyObjectId}/price-history?granularity=month(дефолтmonth;from/toне переданы — берётся полный диапазон «от первого факта до сейчас»). Заголовок — Bearer-токен либо реф-cookie. -
Система проверяет вход:
PropertyObjectIdне пустой;Granularity ∈ {Day, Month, Year}(wireday|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). -
Система читает факты цен по объекту недвижимости за диапазон (partition pruning по
toYYYYMM(EventTime), ключ сортировки поPropertyObjectId) и агрегирует их по бакетам:bucket = toStartOfDay|toStartOfMonth|toStartOfYear(EventTime)поGranularity;- в каждом бакете — «последняя цена периода»:
Price = argMax(NewPrice, (EventTime, IngestionTime)),PricePerMeter = argMax(NewPricePerMeter, (EventTime, IngestionTime));argMaxпо паре сIngestionTimeделает чтение дубль-устойчивым до фонового mergeReplacingMergeTree(FINALзапрещён); - сортировка бакетов по возрастанию. Ожидаемый объём — сотни строк на объект недвижимости (rollup-ов нет осознанно).
-
Система строит ответ:
- каждый бакет →
PricePoint { Time = bucket, Price, PricePerMeter }; growthPercent: приpoints.Count >= 2 && points.First().PricePerMeter > 0→round((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).
- каждый бакет →
-
Фронт рисует график роста; бейдж
growthPercentпоказывается рядом с заголовком; переключение грануляции повторяет шаги 2–5 с новымgranularity.
Альтернативные Потоки
A1. По объекту недвижимости нет фактов (новый объект / несуществующий id)
- ClickHouse возвращает 0 строк →
points = [],growthPercent = 0, HTTP 200. - Фронт показывает состояние «история цены пока не накоплена» (пустой график); ошибкой не считается.
Прогнозная часть графика страницы строится серией
price-per-meterиз UC-PRC-002 независимо.
A2. Неавторизованный доступ без реф-cookie
- Ни JWT, ни валидной реф-cookie → 401; фронт открывает auth-модалку (гейт каталога, как у
/lots). Возврата в основной поток нет до прохождения гейта.
A3. Невалидные параметры
granularityвнеday|month|yearилиfrom > to→ 400 (ValidationError/INVEST_ANALYTICS_INVALID_RANGE); фронт не должен порождать такие запросы — ошибка защищает от ручных вызовов API.
A4. ClickHouse недоступен
- ClickHouse недоступен → 503
INVEST_ANALYTICS_PRICE_HISTORY_UNAVAILABLE; алерт (ошибки 5xx public API); расчёт доходности (UC-PRC-002) продолжает работать — его историческая часть уходит в synthetic-fallback. - Фронт показывает плейсхолдер графика с retry.
A5. Лаг консюмера (свежее изменение цены ещё не видно)
- Изменение цены в Talent произошло < 5 мин назад (Freshness SLA факта) — точка может отсутствовать; это
допустимо по контракту (
freshness ≤ 5 мин, batch ≤ 1000 строк / 5 сек). Никаких действий — следующий запрос увидит точку.
A6. Диапазон задан, но фактов в нём нет (при наличии фактов вне диапазона)
from/toпопали в период без изменений цены (например, узкое окно между изменениями) →points = [],growthPercent = 0, HTTP 200 — как A1, но по срезу диапазона. Ошибкой не считается.- Фронт показывает пустой график для выбранного окна; расширение диапазона или иная грануляция вернут
точки.
growthPercentпри 0/1 точке = 0 (нечего сравнивать).
Постусловие
- Состояние системы не изменилось (read-only): ни PostgreSQL, ни ClickHouse, ни outbox не мутированы.
- Пользователь видит ряд точек цены выбранной грануляции и
growthPercentза диапазон.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
ValidationError | 400 | granularity вне enum, PropertyObjectId пуст, диапазон > 20 лет | Исправить параметры запроса |
INVEST_ANALYTICS_INVALID_RANGE | 400 | from > to | Исправить диапазон |
Unauthorized | 401 | Нет ни JWT, ни валидной реф-cookie | Открыть auth-модалку (гейт каталога) |
INVEST_ANALYTICS_PRICE_HISTORY_UNAVAILABLE | 503 | ClickHouse недоступен (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
| # | Критерий | Проверка |
|---|---|---|
| AC1 | granularity=day/month/year возвращают ряды с бакетами toStartOfDay/Month/Year и последней ценой периода | интеграционный тест на CH-фикстуре с несколькими фактами внутри одного бакета |
| AC2 | growthPercent = рост PricePerMeter от первой к последней точке диапазона, 1 знак; при < 2 точек = 0 | unit-тест формулы + тест пустого ряда |
| AC3 | Повторная доставка события цены (дубль до merge) не искажает значения бакета (argMax по IngestionTime, без FINAL) | тест с дублированной вставкой факта |
| AC4 | Реф-гость без токена получает график (реф-cookie проходит гейт); без cookie — 401 | e2e-тест двух режимов гейта |
| AC5 | Пустой ряд по несуществующему объекту недвижимости — 200, не 404 | API-тест |
| AC6 | Недоступный ClickHouse → 503 с кодом, без падения процесса | тест с отключённым CH |
| AC7 | Частичный диапазон (from без to и наоборот) трактуется как открытый; заданный диапазон без фактов → 200 пустой ряд, growthPercent = 0 | API-тесты открытого и пустого (по срезу) диапазона |
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (1): UC-PRC-002. Расчёт доходности объекта недвижимости с выбором субъекта (юр/физ) и налогового режима
- jobs (1): Background Job: PriceHistoryBackfillJob
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- API метод (дом контракта): ../api/public/get-property-object-price-history.md.
- Domain: ../domain/price-history-fact.md (колонки, dedup, партиции, «без TTL»).
- Consumer-источник данных: ../consumers/chessboard-price-changed.md.
- Backfill: ../jobs/price-history-backfill.md.
- Событие-источник:
docs/06-services/chessboard/events/property-object-price-changed.md(контекстchessboard). - Потребитель истории цен вне страницы:
docs/06-services/investor-owner/(динамика портфеля — читает этот же public API). - Смежный UC: UC-PRC-002 (историческая часть серии
price-per-meter). - Решения: ADR-0056 (без rollup-ов), ADR-0057 (паттерн CH-facts).