UC-CHS-003. Изменение цены объекта застройщиком (события в аналитику)
Назначение
Менеджер застройщика меняет цену объекта недвижимости: пересчитывается цена за м², публикуется
pin.chessboard.property-object-price-changed.v1 (append-only история цен и график роста в
invest-analytics, обновление витрины catalog) и пересчитывается денормализованный диапазон цен
проекта. Сценарий относится к контексту chessboard.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-CHS-003 |
| Контекст | chessboard (см. docs/06-services/chessboard/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | Девелопер (менеджер застройщика, внутренняя роль «менеджер продаж») |
| Permissions / scope | chessboard.property-objects.manage; scope = организация / группа проектов / проект |
| Tenant | TenantId = CompanyId застройщика (ADR-0052); объекты недвижимости видны только своему tenant-у |
| Record-level | Не применяется |
Предусловие
- Объект недвижимости существует, принадлежит tenant-у актора и не в статусе
Sold(INV-CHS-008). - Для объекта недвижимости-реплики Talent: цена — PIN-управляемое поле (Talent-цены тоже приходят синком,
но правка через Developers API допустима — «дополнять и корректировать»); последняя запись побеждает,
история обеих сторон сохраняется в
invest-analyticsчерез события.
Триггер
| Поле | Значение |
|---|---|
| Источник | Действие пользователя в кабинете застройщика |
| Триггерящий элемент | Кабинет застройщика → корпус → таблица объектов недвижимости / карточка объекта недвижимости → поле «Цена» → «Сохранить» |
Основной Поток
-
Менеджер меняет цену объекта недвижимости (одного или пакетно по списку) и сохраняет (
PUT /developers/property-objects/{id}/price, тело{ NewPrice: decimal, RowVersion: uint }). -
Проверяется вход:
NewPrice >= 0, точностьnumeric(14,2). Объект недвижимости должен существовать (иначе404), принадлежать актору и не бытьSold(INV-CHS-008). Валюта (CurrencyId— ссылка на справочник) уже зафиксирована на объекте недвижимости при создании (INV-PRJ-6) — родительский проект не читается. -
Применение смены цены:
- при
Status = Sold→CHESSBOARD_PROPERTY_OBJECT_PRICE_LOCKED(INV-CHS-008); - при
Status in (Reserved, Booked)операция допускается, но поднимается warning-событиеPropertyObjectPriceChangedUnderHoldEvent(цена в сделке зафиксирована CRM отдельно и не меняется — граница ответственности); - маппинг поле-в-поле:
OldPrice = Price(значение до правки),Price = NewPrice,PricePerMeter = round(NewPrice / TotalArea, 2),UpdatedByUserId = actor,Version += 1; - если
NewPrice == Price→ операция без эффекта (см. A4); - конкурентный доступ по
RowVersion: устаревшая версия →409.
Поднимаются
PropertyObjectPriceChangedEventиProjectAggregatesRecalcRequestedEvent(ProjectId). - при
-
В одном согласованном изменении с мутацией публикуется
PropertyObjectPriceChangedKafkaEvent(ContractVersion = 1, топикpin.chessboard.property-object-price-changed.v1, key =PropertyObjectId): payloadPropertyObjectId,TenantId,ProjectId,OldPrice,NewPrice,CurrencyId+CurrencySnapshot(snapshots.md),PricePerMeter,ChangedAt = now,Source = DeveloperApi(PropertyObjectStatusSource),Version.PropertyObjectUpsertedKafkaEventпри этом не публикуется (цена не паспортное поле — снапшот цены несёт price-событие и следующий upsert). -
Результат:
200 OK—Id,Price,PricePerMeter,Version,RowVersion. -
invest-analyticsпотребляетproperty-object-price-changedи дописывает точку в append-only историю цен (ClickHouse) — график роста цены полной карточки объекта;catalogобновляет цену в витрине и шахматке. -
Job
project-aggregates-recalc(debounce 30 с) пересчитываетProject.Price(min/max(Price)по свободным объектам недвижимости); при изменении диапазона публикуетProjectUpsertedKafkaEvent(лента каталога показывает новый диапазон, фильтр «ценовой диапазон» работает по свежим значениям). -
Фронт обновляет строку объекта недвижимости (цена, цена/м²).
Альтернативные Потоки
A1. Объект недвижимости продан
Status = Sold→CHESSBOARD_PROPERTY_OBJECT_PRICE_LOCKED(INV-CHS-008),409. Цена проданного объекта недвижимости — исторический факт; правка запрещена всем акторам.
A2. Объект недвижимости под холдом (Резерв/Бронь)
Status in (Reserved, Booked): операция выполняется, но поднимаетсяPropertyObjectPriceChangedUnderHoldEvent(warning-лог + метрикаchessboard_price_changed_under_hold_total).- Фронт показывает предупреждение «объект в резерве/брони — цена в активной сделке не изменится» (цену сделки фиксирует CRM в момент резерва). Возврат в основной поток на шаг 3.
A3. Смена цены синком Talent
- Цена пришла событием синка (UC-CHS-005): та же смена цены с
Source = TalentSync— публикуется тот жеPropertyObjectPriceChangedKafkaEvent; история цен вinvest-analyticsедина для обоих источников (полеSourceразличает).
A4. Повтор с той же ценой
NewPrice == Price→ операция без эффекта: успех без мутаций, событий и инкрементаVersion(идемпотентность повторного сабмита формы).
A5. Конкурентная гонка «правка цены vs CRM-событие статуса»
- Consumer CRM (UC-CHS-006) и команда цены меняют один объект недвижимости одновременно — один из них
получает конфликт
RowVersion: HTTP-команда →409(клиент повторяет), consumer — retry с перечитыванием агрегата. Потерянных обновлений нет.
A6. Пакетная правка с частичным отказом
- Пакетная правка (менеджер меняет цену по списку объектов недвижимости) выполняется как
последовательность независимых операций над отдельными объектами: у каждого свой
RowVersion, своя атомарная мутация и своё событие. Отказ по одному объекту недвижимости не откатывает остальные. - В списке может встретиться проданный (
Status = Sold→CHESSBOARD_PROPERTY_OBJECT_PRICE_LOCKED), чужой/несуществующий (CHESSBOARD_PROPERTY_OBJECT_NOT_FOUND) или устаревший по версии (409) объект недвижимости — он отклоняется индивидуально, применённые объекты остаются изменёнными. - Фронт показывает построчный результат (применено / код отказа по каждому объекту недвижимости).
Событие
property-object-price-changedпубликуется только по фактически изменённым объектам (каждое со своим ключомPropertyObjectId);ProjectAggregatesRecalcRequestedEventзатронутого проекта схлопывается debounce-окном job-а в один пересчёт.
Межсервисный контракт и согласованность
Что шахматка предоставляет. PropertyObjectPriceChangedKafkaEvent.v1 (топик
pin.chessboard.property-object-price-changed.v1, key = PropertyObjectId, ContractVersion = 1,
at-least-once). Гарантии: ценовые точки одного объекта недвижимости упорядочены (общий ключ партиции);
поле Source (DeveloperApi/TalentSync) различает инициатора; пара OldPrice/NewPrice и ChangedAt
позволяют потребителю строить монотонную по времени историю. Потребители обязаны быть идемпотентны по
(PropertyObjectId, Version) — повторная доставка не должна задваивать точку истории. Эволюция payload:
новые optional-поля — additive; удаление/переименование/смена типа — breaking (координируется с
invest-analytics и catalog).
Окно согласованности (что видит пользователь).
- Шахматка — source of truth цены: новое значение и
PricePerMeterвидны немедленно (read-committed) в шахматке и карточке объекта недвижимости сразу после200 OK. catalog(цена в ленте, фильтр «ценовой диапазон») обновляется асинхронно после доставки события (единицы секунд); денормализованный диапазонProject.Price— после debounce-окна job-а (30 с) и событияproject-upserted. В этом окне лента каталога может показывать прежний диапазон — это ожидаемое состояние, не ошибка.invest-analyticsдописывает точку истории с тем же лагом; график роста цены появляется асинхронно.- Недоступность
catalog/invest-analyticsне блокирует смену цены: событие остаётся в очереди и будет доставлено (at-least-once), проекции и история догонят состояние после восстановления потребителя.
Наблюдаемость / НФТ. Логи property-object-price-changed (objectId, tenantId, projectId, oldPrice,
newPrice — без PII); метрики: счётчик смен цены, chessboard_price_changed_under_hold_total (A2),
латентность операции. Нагрузка — единичные ручные правки (ADR-0056), поэтому пакетная правка допускает
последовательную обработку без спец-оптимизаций; целевая латентность одиночной операции — десятки мс.
Постусловие
PropertyObject.Price = NewPrice,PropertyObject.PricePerMeterпересчитан,Versionинкрементирован,UpdatedByUserId = actor.pin.chessboard.property-object-price-changed.v1доставлен:invest-analyticsдописал точку истории (OldPrice→NewPrice, ChangedAt),catalogобновил витрину.Project.Priceактуальны (после debounce-окна job-а); при изменении диапазона опубликованproject-upserted.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
CHESSBOARD_PROPERTY_OBJECT_PRICE_LOCKED | 409 | INV-CHS-008: Status = Sold (A1) | Показать «объект продан, цена зафиксирована» |
CHESSBOARD_PROPERTY_OBJECT_NOT_FOUND | 404 | Объект недвижимости не существует / чужой tenant | Обновить список объектов недвижимости |
CHESSBOARD_PROPERTY_OBJECT_FIELD_SYNC_MANAGED | 409 | Попытка через общий update править реплика-поля (см. UC-CHS-002 A5); цены не касается | Использовать метод /price |
| — (validation) | 400 | NewPrice < 0 или превышение precision | Подсветить поле |
| — (concurrency) | 409 | RowVersion устарел (A5) | Перечитать объект недвижимости, повторить |
| — (forbidden) | 403 | Нет chessboard.property-objects.manage в scope проекта | Ошибка доступа |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
PropertyObjectPriceChangedEvent | Успешная смена цены с фактической сменой значения | in-proc |
PropertyObjectPriceChangedUnderHoldEvent | Смена цены при Reserved/Booked (A2) | in-proc: warning-лог, метрика |
ProjectAggregatesRecalcRequestedEvent | Каждая фактическая смена цены | job project-aggregates-recalc (debounce 30 с) |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
PropertyObjectPriceChangedKafkaEvent.v1 | pin.chessboard.property-object-price-changed.v1 | Каждое фактическое изменение Price (в т.ч. синком — A3), в той же транзакции с мутацией | invest-analytics (append-only история цен, график роста, прогноз доходности), catalog (цена витрины/шахматки) |
ProjectUpsertedKafkaEvent.v1 | pin.chessboard.project-upserted.v1 | Job пересчитал и изменил Price (шаг 7) | catalog, dev-stats, identity, news |
Acceptance Criteria
| # | Критерий | Проверка |
|---|---|---|
| AC1 | Смена цены публикует событие с корректной парой OldPrice/NewPrice в той же транзакции | outbox-тест: rollback → события нет |
| AC2 | PricePerMeter пересчитан в той же мутации | unit-тест смены цены |
| AC3 | Цена Sold-объекта недвижимости не меняется никаким актором | тест INV-CHS-008 |
| AC4 | Смена цены под холдом проходит с warning-событием и метрикой | тест A2 |
| AC5 | Повтор той же цены — без эффекта и без события (A4) | тест идемпотентности |
| AC6 | Project.Price сходятся с min/max свободных объектов недвижимости после серии смен цен | интеграционный тест job-а |
| AC7 | Менеджер чужой компании получает 404/403, событие не публикуется | тест tenant-изоляции |
| AC8 | В invest-analytics история точек монотонна по ChangedAt и содержит Source | контрактный тест consumer-а invest-analytics |
| AC9 | Пакетная правка с проданным/чужим объектом недвижимости в списке применяет остальные, отклонённые возвращает построчно (A6) | тест частичного отказа пакета |
| AC10 | В окне до доставки события лента catalog показывает прежний диапазон, шахматка — новую цену; после доставки диапазоны сходятся | тест окна согласованности |
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): POST /api/v1/developers/property-objects/{propertyObjectId}/price — Изменение цены объекта недвижимости
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- API метод: POST /api/v1/developers/property-objects/{propertyObjectId}/price — Изменение цены объекта недвижимости.
- Domain entity: ../domain/property-object.md (INV-CHS-008, событие
PropertyObjectPriceChangedKafkaEvent), ../domain/project.md (Price). - Consumer истории цен: Consumer: ChessboardPropertyObjectPriceChangedConsumer
(чужой контекст
invest-analytics). - Background: ../background/project-aggregates-recalc.md.
- Связанные UC: UC-CHS-002 (создание объекта недвижимости), UC-CHS-005 (цены синком), UC-CHS-006 (статусы CRM).