UC-PRM-001. Покупка продвижения (компания)
Назначение
Описывает сценарий: менеджер компании (застройщик/агентство) покупает платформенный пакет продвижения
для набора целей (проект / объекты недвижимости / посты) без оплаты (решение №10). Связан с
контекстом promotion.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-PRM-001 |
| Контекст | promotion (см. docs/06-services/promotion/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | Девелопер / Агентство (менеджер компании) |
| Permissions / scope | promotion.purchases.manage в scope организации |
| Tenant | TenantId = CompanyId (ADR-0052) |
| Record-level | не применяется (владение целью проверяет контекст-владелец) |
Предусловие
- Пакет
PromotionPackageсуществует в статусеActive(опубликован админ-панелью — UC-PRM-002). - Feature flag
promotion.enabled = true. - Цели (
Project/PropertyObject/Post) принадлежат компании актора и существуют в контекстах-владельцах.
Триггер
| Поле | Значение |
|---|---|
| Источник | UI button / API call |
| Триггерящий элемент | экран «Продвижение» ЛК компании → кнопка «Купить пакет» |
Основной Поток
- Менеджер открывает экран «Продвижение», видит доступные пакеты (
GET /api/developers/v1/promotion-packages). - Выбирает пакет и цели (набор
Targets[] { TargetKind, TargetId }, ограниченныйPackageKind). - Фронт шлёт
POST /api/developers/v1/promotionsсPackageIdиTargets[]. - Система проверяет доступ (permission), feature flag
promotion.enabledи принадлежность компании. - Система проверяет предусловия: пакет
Active(INV-3),TargetKind/кол-во поPackageKind(INV-1), владение целями через контекст-владелец (INV-4, fail-closed), отсутствие пересечения (INV-5). - Покупка создаётся со снапшотом пакета и сразу активируется (
Pending → Active,PaymentStatus = NotRequired); в одном согласованном изменении публикуетсяPromotionPurchaseChangedKafkaEvent(Status = Active). - Ответ
201сPromotionPurchaseResult(Status = Active,EndsAt). - Фронт показывает активную покупку сразу (source of truth —
promotion, лага нет). Потребителиcatalog/newsполучают событие асинхронно и поднимаютPromotionBoostцелей у себя — до этого момента (окно eventual-consistency, целевой SLA < 1 мин) покупка ужеActive, но карточка проекта/объекта/пост ещё показывается без нового буста.notificationsасинхронно шлёт уведомление «продвижение запущено».
Межсервисная последовательность
| Шаг | Кто → кому | Синхронность | Что | Промежуточное состояние |
|---|---|---|---|---|
| 1 | promotion → контекст-владелец (catalog/news) | синхронно (internal read) | резолвинг владельца каждой цели (INV-4); при недоступности — fail-closed | покупка ещё не создана |
| 2 | promotion (сам) | синхронно, атомарно | создание+активация покупки и запись события в одном согласованном изменении | покупка Active, событие ждёт публикации |
| 3 | promotion → топик pin.promotion.purchase-changed.v1 | асинхронно (at-least-once) | публикация Status = Active, key = PurchaseId | у потребителей буст ещё старый |
| 4 | топик → catalog/news | асинхронно (< 1 мин) | пересчёт PromotionBoost карточек/поста (сумма BoostWeight активных покупок) | буст целей поднят |
| 5 | топик → notifications | асинхронно (< 5 мин) | уведомление менеджеру | — |
Альтернативные Потоки
A1. Пакет недоступен
- Пакет не найден или
Status != Active(INV-3) →PROMOTION_PACKAGE_NOT_AVAILABLE(409). Покупка не создаётся, событий нет. Завершается.
A2. Цель не принадлежит компании / контекст-владелец недоступен
- Синхронный резолвинг владельца цели вернул несовпадение владельца, несуществование цели или был
недоступен (fail-closed, DSC-PRM-1) →
PROMOTION_TARGET_NOT_OWNED(403). Покупка не создаётся. Завершается.
A3. Продвижение уже активно (пересечение / повтор)
- Для (
PackageId, цель) уже есть покупка в статусеPending/Active(INV-5) →PROMOTION_ALREADY_ACTIVE(409). Повтор той же операции упирается сюда же (естественная идемпотентность). Завершается.
A4. Покупка временно отключена
- Feature flag
promotion.enabled = false→PROMOTION_DISABLED(409) до всякой загрузки. Витрина пакетов при этом остаётся видимой (PurchaseEnabled = false). Завершается.
A5. Цели не соответствуют виду пакета
- Набор
TargetKind/количество целей не допустим дляPackageKind(INV-1:ProjectBoost— ровно 1,PropertyObjectBoost/PostBoost—1..MaxTargetsPerPurchase), либо цели не distinct →PROMOTION_TARGETS_INVALID(422). Завершается.
A6. Конкурентная покупка той же цели
- Две одновременные покупки на одну цель: одна фиксируется, вторая упирается в INV-5 + уникальное
ограничение целей → проигравшая получает
PROMOTION_ALREADY_ACTIVE(409). Двойного буста нет.
Постусловие
- При успехе: создана покупка
PromotionPurchaseв статусеActive(PaymentStatus = NotRequired) с целямиPromotionPurchaseTarget; снапшотBoostWeight/Price/PackageKindзафиксирован; счётчик покупок компании увеличен; опубликованоPromotionPurchaseChangedKafkaEvent(Status = Active); в пределах окна консистентности буст целей поднят у потребителейcatalog/news. - При любом отказе (A1–A6): покупка не создана, событий нет, состояние потребителей не изменилось.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
PROMOTION_DISABLED | 409 | Feature flag выключен | Показать «покупка временно недоступна» |
PROMOTION_PACKAGE_NOT_AVAILABLE | 409 | Пакет не Active | Обновить список пакетов |
PROMOTION_TARGETS_INVALID | 422 | Цели не соответствуют PackageKind | Поправить выбор целей |
PROMOTION_TARGET_NOT_OWNED | 403 | Цель чужая/недоступна | Убрать цель из выбора |
PROMOTION_ALREADY_ACTIVE | 409 | Дубль активной покупки | Показать существующую покупку |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
| — | пересчёта буста в промо-контексте нет (у консюмеров, ADR-0060) | — |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
PromotionPurchaseChangedKafkaEvent | pin.promotion.purchase-changed.v1 | Активация покупки (Status = Active) | catalog, news (буст), notifications, будущий billing |
Acceptance Criteria
| # | Given | When | Then |
|---|---|---|---|
| AC1 (happy) | Пакет Active, цели принадлежат компании, пересечений нет, promotion.enabled = true | менеджер покупает пакет для допустимого набора целей | покупка создана Active, PaymentStatus = NotRequired, EndsAt = StartsAt + DurationDays, Version = 1, 201 |
| AC2 (снапшот) | Есть действующая покупка; администратор меняет BoostWeight/Price пакета | правка пакета сохранена (Active → Active) | покупка сохраняет прежние BoostWeight/Price/PackageKind (INV-2); событий пересчёта нет |
| AC3 (атомарность) | Условия happy-path выполнены | покупка активируется | PromotionPurchaseChangedKafkaEvent (Status = Active, key = PurchaseId) публикуется в одном согласованном изменении со сменой статуса; в пределах окна < 1 мин буст поднят у catalog/news |
| AC4 (INV-1) | Пакет ProjectBoost | менеджер передаёт 2 цели или цель типа PropertyObject | отказ PROMOTION_TARGETS_INVALID (422); покупка не создана |
| AC5 (INV-3) | Пакет в статусе Draft/Archived (или не существует) | менеджер покупает этот пакет | отказ PROMOTION_PACKAGE_NOT_AVAILABLE (409) |
| AC6 (INV-4) | Одна из целей принадлежит другой компании либо контекст-владелец недоступен | менеджер покупает набор с этой целью | отказ PROMOTION_TARGET_NOT_OWNED (403, fail-closed); покупка не создана |
| AC7 (INV-5) | На (PackageId, цель) уже есть Active-покупка | менеджер покупает тот же пакет на ту же цель | отказ PROMOTION_ALREADY_ACTIVE (409); двойного буста нет |
| AC8 (flag) | promotion.enabled = false | менеджер вызывает покупку | отказ PROMOTION_DISABLED (409); витрина пакетов остаётся видимой |
| AC9 (идемпотентность) | Первая покупка прошла успешно | менеджер повторяет тот же запрос | вторая упирается в INV-5 → PROMOTION_ALREADY_ACTIVE; второй покупки/события нет |
Обратные ссылки
Автоссылки: где используется этот документ.
- scenarios (1): UC-OVR-009. Продвижение поста / объекта недвижимости
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- API метод: ../api/developers/purchase-promotion.md.
- Domain entity: ../domain/promotion-purchase.md, ../domain/promotion-package.md.
- Событие: ../events/promotion-purchase-changed.md.
- ADR-0060 (полиморфный таргет, консюмеры буста); решение №10 technical-assignment.md.