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

UC-CHS-003. Изменение цены объекта застройщиком (события в аналитику)

Назначение

Менеджер застройщика меняет цену объекта недвижимости: пересчитывается цена за м², публикуется pin.chessboard.property-object-price-changed.v1 (append-only история цен и график роста в invest-analytics, обновление витрины catalog) и пересчитывается денормализованный диапазон цен проекта. Сценарий относится к контексту chessboard.

Metadata

ПолеЗначение
IDUC-CHS-003
Контекстchessboard (см. docs/06-services/chessboard/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольДевелопер (менеджер застройщика, внутренняя роль «менеджер продаж»)
Permissions / scopechessboard.property-objects.manage; scope = организация / группа проектов / проект
TenantTenantId = CompanyId застройщика (ADR-0052); объекты недвижимости видны только своему tenant-у
Record-levelНе применяется

Предусловие

  • Объект недвижимости существует, принадлежит tenant-у актора и не в статусе Sold (INV-CHS-008).
  • Для объекта недвижимости-реплики Talent: цена — PIN-управляемое поле (Talent-цены тоже приходят синком, но правка через Developers API допустима — «дополнять и корректировать»); последняя запись побеждает, история обеих сторон сохраняется в invest-analytics через события.

Триггер

ПолеЗначение
ИсточникДействие пользователя в кабинете застройщика
Триггерящий элементКабинет застройщика → корпус → таблица объектов недвижимости / карточка объекта недвижимости → поле «Цена» → «Сохранить»

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

  1. Менеджер меняет цену объекта недвижимости (одного или пакетно по списку) и сохраняет (PUT /developers/property-objects/{id}/price, тело { NewPrice: decimal, RowVersion: uint }).

  2. Проверяется вход: NewPrice >= 0, точность numeric(14,2). Объект недвижимости должен существовать (иначе 404), принадлежать актору и не быть Sold (INV-CHS-008). Валюта (CurrencyId — ссылка на справочник) уже зафиксирована на объекте недвижимости при создании (INV-PRJ-6) — родительский проект не читается.

  3. Применение смены цены:

    • при Status = SoldCHESSBOARD_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).

  4. В одном согласованном изменении с мутацией публикуется PropertyObjectPriceChangedKafkaEvent (ContractVersion = 1, топик pin.chessboard.property-object-price-changed.v1, key = PropertyObjectId): payload PropertyObjectId, TenantId, ProjectId, OldPrice, NewPrice, CurrencyId + CurrencySnapshot (snapshots.md), PricePerMeter, ChangedAt = now, Source = DeveloperApi (PropertyObjectStatusSource), Version. PropertyObjectUpsertedKafkaEvent при этом не публикуется (цена не паспортное поле — снапшот цены несёт price-событие и следующий upsert).

  5. Результат: 200 OKId, Price, PricePerMeter, Version, RowVersion.

  6. invest-analytics потребляет property-object-price-changed и дописывает точку в append-only историю цен (ClickHouse) — график роста цены полной карточки объекта; catalog обновляет цену в витрине и шахматке.

  7. Job project-aggregates-recalc (debounce 30 с) пересчитывает Project.Price (min/max(Price) по свободным объектам недвижимости); при изменении диапазона публикует ProjectUpsertedKafkaEvent (лента каталога показывает новый диапазон, фильтр «ценовой диапазон» работает по свежим значениям).

  8. Фронт обновляет строку объекта недвижимости (цена, цена/м²).

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

A1. Объект недвижимости продан

  1. Status = SoldCHESSBOARD_PROPERTY_OBJECT_PRICE_LOCKED (INV-CHS-008), 409. Цена проданного объекта недвижимости — исторический факт; правка запрещена всем акторам.

A2. Объект недвижимости под холдом (Резерв/Бронь)

  1. Status in (Reserved, Booked): операция выполняется, но поднимается PropertyObjectPriceChangedUnderHoldEvent (warning-лог + метрика chessboard_price_changed_under_hold_total).
  2. Фронт показывает предупреждение «объект в резерве/брони — цена в активной сделке не изменится» (цену сделки фиксирует CRM в момент резерва). Возврат в основной поток на шаг 3.

A3. Смена цены синком Talent

  1. Цена пришла событием синка (UC-CHS-005): та же смена цены с Source = TalentSync — публикуется тот же PropertyObjectPriceChangedKafkaEvent; история цен в invest-analytics едина для обоих источников (поле Source различает).

A4. Повтор с той же ценой

  1. NewPrice == Price → операция без эффекта: успех без мутаций, событий и инкремента Version (идемпотентность повторного сабмита формы).

A5. Конкурентная гонка «правка цены vs CRM-событие статуса»

  1. Consumer CRM (UC-CHS-006) и команда цены меняют один объект недвижимости одновременно — один из них получает конфликт RowVersion: HTTP-команда → 409 (клиент повторяет), consumer — retry с перечитыванием агрегата. Потерянных обновлений нет.

A6. Пакетная правка с частичным отказом

  1. Пакетная правка (менеджер меняет цену по списку объектов недвижимости) выполняется как последовательность независимых операций над отдельными объектами: у каждого свой RowVersion, своя атомарная мутация и своё событие. Отказ по одному объекту недвижимости не откатывает остальные.
  2. В списке может встретиться проданный (Status = SoldCHESSBOARD_PROPERTY_OBJECT_PRICE_LOCKED), чужой/несуществующий (CHESSBOARD_PROPERTY_OBJECT_NOT_FOUND) или устаревший по версии (409) объект недвижимости — он отклоняется индивидуально, применённые объекты остаются изменёнными.
  3. Фронт показывает построчный результат (применено / код отказа по каждому объекту недвижимости). Событие 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_LOCKED409INV-CHS-008: Status = Sold (A1)Показать «объект продан, цена зафиксирована»
CHESSBOARD_PROPERTY_OBJECT_NOT_FOUND404Объект недвижимости не существует / чужой tenantОбновить список объектов недвижимости
CHESSBOARD_PROPERTY_OBJECT_FIELD_SYNC_MANAGED409Попытка через общий update править реплика-поля (см. UC-CHS-002 A5); цены не касаетсяИспользовать метод /price
— (validation)400NewPrice < 0 или превышение precisionПодсветить поле
— (concurrency)409RowVersion устарел (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.v1pin.chessboard.property-object-price-changed.v1Каждое фактическое изменение Price (в т.ч. синком — A3), в той же транзакции с мутациейinvest-analytics (append-only история цен, график роста, прогноз доходности), catalog (цена витрины/шахматки)
ProjectUpsertedKafkaEvent.v1pin.chessboard.project-upserted.v1Job пересчитал и изменил Price (шаг 7)catalog, dev-stats, identity, news

Acceptance Criteria

#КритерийПроверка
AC1Смена цены публикует событие с корректной парой OldPrice/NewPrice в той же транзакцииoutbox-тест: rollback → события нет
AC2PricePerMeter пересчитан в той же мутацииunit-тест смены цены
AC3Цена Sold-объекта недвижимости не меняется никаким акторомтест INV-CHS-008
AC4Смена цены под холдом проходит с warning-событием и метрикойтест A2
AC5Повтор той же цены — без эффекта и без события (A4)тест идемпотентности
AC6Project.Price сходятся с min/max свободных объектов недвижимости после серии смен ценинтеграционный тест job-а
AC7Менеджер чужой компании получает 404/403, событие не публикуетсятест tenant-изоляции
AC8В invest-analytics история точек монотонна по ChangedAt и содержит Sourceконтрактный тест consumer-а invest-analytics
AC9Пакетная правка с проданным/чужим объектом недвижимости в списке применяет остальные, отклонённые возвращает построчно (A6)тест частичного отказа пакета
AC10В окне до доставки события лента catalog показывает прежний диапазон, шахматка — новую цену; после доставки диапазоны сходятсятест окна согласованности

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

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

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