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

UC-PRM-002. Администратор управляет пакетами продвижения

Назначение

Описывает сценарий жизненного цикла платформенного пакета продвижения (PromotionPackage): администратор платформы через admin API заводит пакет (черновик), правит его поля, публикует его на витрину застройщика и снимает с продажи (архивация). Пакет — платформенная сущность каталога монетизации (не tenanted); он определяет вид продвижения, вес буста, длительность и прейскурантную цену будущих покупок. Оплата не реализуется (решение №10) — цена информационная. Связан с контекстом promotion.

Metadata

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

Актор

ПолеЗначение
РольАдминистратор платформы (через admin API — ADR-0053)
Permissions / scopepromotion.packages.admin, policy AdminSystemOnly; scope — платформа
Tenantnull (пакеты не tenanted — ADR-0052 п.3)
Record-levelне применяется

Предусловие

  • Admin-система аутентифицирована m2m client-credentials (pin-admin-system), передаёт X-Admin-User-Id.
  • Актор имеет permission promotion.packages.admin в scope платформы (policy AdminSystemOnly); иначе операция отклоняется до бизнес-логики (FORBIDDEN, при отсутствии/просрочке m2m-токена — UNAUTHORIZED).
  • Для правки/публикации/архивации целевой пакет существует и не архивирован: архивный (soft-deleted) пакет неотличим от несуществующего (PROMOTION_PACKAGE_NOT_FOUND).
  • Мутации сопровождаются заголовком Idempotency-Key (admin API ретраит по таймауту); для оптимистической защиты правки может передаваться ExpectedVersion.
  • Контекст запуска не привязан к компании (пакет — каталог платформы).

Триггер

ПолеЗначение
Источникдействие администратора → admin API promotion
Триггерящий элементраздел «Пакеты продвижения» → «Создать/Изменить/Опубликовать/Архивировать»

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

  1. Просмотр каталога. Админ открывает раздел «Пакеты продвижения» и запрашивает управленческий список (GET /api/admin/v1/promotion-packages) — пакеты всех статусов (Draft/Active/Archived, архив — по правилу видимости) с аудит-проекцией и счётчиком действующих покупок ActivePurchasesCount (чтобы видеть черновики и последствия архивации). Витрина застройщика этих данных не видит.
  2. Создание черновика. Админ создаёт пакет (POST /api/admin/v1/promotion-packages: Code, Name, Kind, BoostWeight, DurationDays, MaxTargetsPerPurchase, Price). Система проверяет форматы и диапазоны полей, кросс-поле MaxTargetsPerPurchase ⇔ Kind (INV-2 пакета) и уникальность Code среди живых пакетов (INV-1); при успехе пакет создаётся в статусе Draft ([*] → Draft, Version = 1) и невидим покупателям — покупка невозможна. Ответ 201 + PromotionPackageResult (Status = Draft).
  3. Правка полей. Админ меняет Name/Description/BoostWeight/DurationDays/MaxTargetsPerPurchase/ Price без смены статуса (PUT /api/admin/v1/promotion-packages/{id}, целевой Status = текущему). Code и Kind в контракт правки не входят и неизменяемы (INV-3); присланные и отличные от сохранённых → отказ. Version инкрементируется. Правка действующего (Active) пакета применяется только к будущим покупкам — действующие покупки держат снапшот (INV-2 покупки), событий пересчёта нет.
  4. Публикация. Админ переводит черновик в продажу (PUT, Status = Active; переход Draft → Active по матрице). Пакет становится виден и покупаем немедленно на витрине застройщика (GET /promotion-packages отдаёт только Active); лага нет — source of truth в этом контексте.
  5. Снятие с продажи (архивация). Админ архивирует пакет (DELETE /api/admin/v1/promotion-packages/{id}, симметрично PUT со Status = Archived): soft-delete (Status = Archived, отметка DeletedAt/ DeletedByUserId). Пакет пропадает с витрины; действующие покупки доживают срок и истекают штатно (FK restrict, INV-2 снапшота покупки) — архив пакета их не трогает.
  6. Ответ и аудит. Каждая мутация отвечает 201/200 с PromotionPackageResult (при архиве — с ArchivedAt) и фиксируется в глубоком аудите (актор X-Admin-User-Id, packageId/Code, диф полей, переход статуса, результат). Повтор мутации с тем же Idempotency-Key возвращает ранее сохранённый результат без повторного применения.

Инварианты, статусы и валидации

Инварианты PromotionPackage

ИнвариантУсловиеНарушение
INV-1 CodeUniqueCode уникален среди живых пакетов (deleted_at is null)PROMOTION_PACKAGE_CODE_DUPLICATE
INV-2 MaxTargetsMatchesKindMaxTargetsPerPurchase задан ⇔ Kind ∈ {PropertyObjectBoost, PostBoost}; null при ProjectBoostPROMOTION_PACKAGE_MAX_TARGETS_REQUIRED / _NOT_APPLICABLE (create) · PROMOTION_PACKAGE_MAX_TARGETS_RULE (update)
INV-3 ImmutableIdentityCode и Kind неизменяемы после созданияPROMOTION_PACKAGE_IMMUTABLE_FIELD
INV-4 StatusTransitionпереходы Status только по матрице нижеPROMOTION_PACKAGE_INVALID_STATUS_TRANSITION
INV-5 CurrencyRefPrice.CurrencyId существует в справочнике валют и не архивен (internal resolve handbook); допустима только RUB (ADR-0048)PROMOTION_CURRENCY_UNKNOWN (неизвестный/архивный id); не-RUB → ValidationError
INV-2 покупки (SnapshotImmutable)правка/архив пакета не меняют уже созданные покупки (снапшот BoostWeight/Price/PackageKind)— (событий пересчёта нет)

Матрица переходов PromotionPackageStatus

Из \ ВDraftActiveArchived
Draft✅ правка полей✅ публикация✅ архив черновика
Active⛔ unpublish не поддержан → PROMOTION_PACKAGE_INVALID_STATUS_TRANSITION✅ правка полей✅ архивация (покупки доживают срок)
Archived⛔ терминально (soft-deleted → PROMOTION_PACKAGE_NOT_FOUND)

Инициатор всех переходов — администратор (create/update/delete). Эффект: Draft — невидим покупателям; Active — покупаем на витрине немедленно; Archived — снят с продажи, действующие покупки истекают штатно.

Валидации полей (границы)

Code — pattern ^[a-z0-9][a-z0-9-]*$, ≤ 64; Name 1..256 (после trim); Description ≤ 2000; Kind ∈ {ProjectBoost, PropertyObjectBoost, PostBoost}; BoostWeight 1..100; DurationDays 1..365; MaxTargetsPerPurchase 1..500 (условно по INV-2); Price.Amount decimal(14,2) >= 0 (0 — бесплатный пакет); Price.CurrencyId — ссылка на справочник валют handbook (default RUB; резолв через internal resolve, неизвестный/архивный id → PROMOTION_CURRENCY_UNKNOWN; допустима только RUB — ADR-0048). Нарушение формата/диапазона → ValidationError.

Межсервисные последовательности и контракты

  • Исходящих интеграционных событий операция не публикует. Единственный produced-контракт promotionPromotionPurchaseChangedKafkaEvent (pin.promotion.purchase-changed.v1) — эмитят переходы покупок, а не пакетов. Правка/публикация/архив пакета не порождают ни доменных, ни Kafka-событий и не затрагивают потребителей буста (catalog/news).
  • Видимость на витрине застройщика (внутри контекста). Витрина (GET /promotion-packages, developers API) читает source of truth того же контекста и отдаёт только Status = Activeокна eventual-consistency нет: Draft → Active делает пакет покупаемым сразу после фиксации, → Archived — сразу убирает с витрины. Управленческий список (admin) видит все статусы.
  • Ожидания от соседей. Синхронно операция ожидает валидный доступ от Identity (permission promotion.packages.admin, m2m-клиент pin-admin-system) и internal resolve справочника валют handbook при валидации Price.CurrencyId (INV-5; недоступность справочника — fail-closed PROMOTION_HANDBOOK_UNAVAILABLE). Целей покупок и объектов чужих контекстов операция не читает (они появляются лишь при покупке пакета).
  • Развязка с действующими покупками. Архив пакета не каскадит на покупки (FK restrict); их снапшот (INV-2 покупки) гарантирует, что буст действующих покупок пересчитывается потребителями только по событиям истечения/отмены самих покупок, а не по правкам пакета.

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

A1. Дубль кода пакета

  1. Code уже существует среди живых пакетов (pre-check или гонка двух одновременных создателей, пойманная уникальным ограничением каталога) → PROMOTION_PACKAGE_CODE_DUPLICATE (409). Сменить Code.

A2. Недопустимый переход статуса

  1. Целевой Status вне матрицы (напр. Active → Draft — unpublish не поддержан; любой переход из Archived) → PROMOTION_PACKAGE_INVALID_STATUS_TRANSITION (409). Обновить состояние формы.

A3. Правка неизменяемого поля

  1. Присланный Code/Kind, отличный от сохранённого → PROMOTION_PACKAGE_IMMUTABLE_FIELD (400). Убрать поле из запроса.

A4. Несогласованный лимит целей

  1. Create: Kind ∈ {PropertyObjectBoost, PostBoost} без MaxTargetsPerPurchasePROMOTION_PACKAGE_MAX_TARGETS_REQUIRED (400); Kind = ProjectBoost с заданным MaxTargetsPerPurchasePROMOTION_PACKAGE_MAX_TARGETS_NOT_APPLICABLE (400); значение вне 1..500ValidationError (400).
  2. Update: нарушение того же правила INV-2 → PROMOTION_PACKAGE_MAX_TARGETS_RULE (400).

A5. Пакет не найден или уже архивирован

  1. Правка/публикация/архив по PackageId, которого нет или который soft-deleted → PROMOTION_PACKAGE_NOT_FOUND (404). Архивный неотличим от несуществующего.

A6. Конкурентная правка двумя админами

  1. Передан ExpectedVersion, не совпавший с текущей Version пакета (или конфликт оптимистической блокировки) → CONCURRENCY_CONFLICT (409). Проигравший перечитывает пакет и повторяет с тем же Idempotency-Key; победитель — тот, чья фиксация прошла первой.

A7. Идемпотентный повтор

  1. Повтор мутации с тем же Idempotency-Key (ретрай admin API по таймауту) → ранее сохранённый результат (201/200), без повторного создания/правки, без второй записи аудита-мутации.

A8. Архивация пакета с действующими покупками

  1. Архив пакета, на который ссылаются Active-покупки, проходит успешно (Status = Archived): покупки не каскадируются (FK restrict), доживают срок и истекают штатно job-ом expire-promotions. Событий по покупкам архив не публикует.

Постусловие

  • Пакет заведён/изменён/опубликован/архивирован; Version инкрементирован; при архиве заполнены DeletedAt/ DeletedByUserId и Status = Archived.
  • Актуальность витрины застройщика соответствует новому Status немедленно (source of truth, без лага): опубликованный пакет покупаем, архивный — недоступен.
  • Действующие покупки не изменены (INV-2 снапшота); потребители буста не затронуты.
  • Запись глубокого аудита мутации сделана; интеграционные и доменные события не публиковались.

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

КодHTTPУсловиеПоведение клиента
ValidationError400Формат/диапазон/enum поля (Code/Name/BoostWeight/DurationDays/MaxTargetsPerPurchase/Price/Kind/Status)Поправить форму
PROMOTION_PACKAGE_MAX_TARGETS_REQUIRED / _NOT_APPLICABLE (create) / _RULE (update)400MaxTargetsPerPurchase не согласован с Kind (INV-2)Поправить форму
PROMOTION_PACKAGE_IMMUTABLE_FIELD400Правка Code/Kind (INV-3)Убрать поле
PROMOTION_CURRENCY_UNKNOWN422Price.CurrencyId не найден в справочнике валют или архивен (INV-5)Исправить валюту
UNAUTHORIZED401Нет/просрочен m2m-токен или X-Admin-User-IdОбновить доступ
FORBIDDEN403Клиент не pin-admin-system / нет promotion.packages.adminПроверить доступ
PROMOTION_PACKAGE_NOT_FOUND404Пакет отсутствует или архивирован (правка/публикация/архив)Перечитать список
PROMOTION_PACKAGE_CODE_DUPLICATE409Дубль Code среди живых (INV-1)Сменить код
PROMOTION_PACKAGE_INVALID_STATUS_TRANSITION409Переход вне матрицы (INV-4)Обновить состояние
CONCURRENCY_CONFLICT409Гонка ExpectedVersion/оптимистической блокировкиПеречитать и повторить (тот же Idempotency-Key)
PROMOTION_HANDBOOK_UNAVAILABLE503Справочник недоступен при резолве Price.CurrencyId (fail-closed)Повторить (тот же Idempotency-Key)

Отказоустойчивость и идемпотентность

  • Идемпотентность: ключ — Idempotency-Key (Guid, обязателен для мутаций). Повтор возвращает сохранённый результат; двойного создания/правки и второй аудит-записи мутации нет.
  • Конкурентность (version-guard): правка «двух админов» разрешается по ExpectedVersion + оптимистической блокировке — побеждает первая зафиксированная мутация, проигравший получает CONCURRENCY_CONFLICT и перечитывает актуальную Version.
  • Гонка на уникальность Code: одновременные создания одного кода отсекаются уникальным ограничением каталога — второй получает PROMOTION_PACKAGE_CODE_DUPLICATE.
  • Компенсаций/саг нет: операция затрагивает единственный агрегат PromotionPackage, эффект атомарен, внешних сайд-эффектов и событий нет — откатывать нечего.

Доменные События

СобытиеКогда поднимаетсяSubscribers
пакет доменных событий не поднимает (v1)

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
изменения пакета интеграционных событий не эмитят (Kafka-события есть только у покупок)

Наблюдаемость и НФТ

  • Аудит: каждая мутация (create/update/publish/archive) — в глубоком аудите: актор X-Admin-User-Id, packageId/Code, диф полей, переход from → to, результат. Чтение управленческого списка — read-sensitive лог.
  • Метрики/логи: promotion_admin_operations_total{operation} (create/update/archive/list-promotion-packages), duration; логи admin-promotion-package-created/updated. Без PII и без значения Price в логах (в логе правки — только набор изменённых полей).
  • Нагрузка/латентность: операции админ-редкие (единичные вызовы), объём каталога пакетов мал — целевая латентность в пределах обычного API-бюджета; тяжёлых зависимостей нет.
  • Доступ/безопасность: только admin API (m2m pin-admin-system, permission promotion.packages.admin), fail-closed при отсутствии доступа; Price — прейскурант, не секрет.

Acceptance Criteria

  • AC1. Создание черновика. Дано валидный запрос создания с уникальным Code. Когда админ вызывает POST. Тогда пакет создаётся в Draft (Version = 1), невидим на витрине застройщика, ответ 201.
  • AC2. Публикация открывает к покупке. Дано Draft-пакет. Когда админ переводит его Draft → Active. Тогда пакет немедленно виден и покупаем на витрине (GET /promotion-packages отдаёт Active), без лага.
  • AC3. Архив не трогает действующие покупки. Дано Active-пакет с действующими покупками. Когда админ архивирует его. Тогда Status = Archived, пакет пропадает с витрины, а покупки доживают срок (INV-2 снапшота, FK restrict) и не получают событий.
  • AC4. Неизменяемость идентичности. Дано существующий пакет. Когда в правке присланы отличные Code/ Kind. Тогда отказ PROMOTION_PACKAGE_IMMUTABLE_FIELD (400), пакет не изменён.
  • AC5. Недопустимый переход. Дано Active-пакет. Когда запрошен Active → Draft. Тогда отказ PROMOTION_PACKAGE_INVALID_STATUS_TRANSITION (409).
  • AC6. Правило лимита целей. Дано Kind = ProjectBoost с заданным MaxTargetsPerPurchase (или multi-target Kind без него). Когда создаётся пакет. Тогда отказ PROMOTION_PACKAGE_MAX_TARGETS_NOT_APPLICABLE/ _REQUIRED (400) по INV-2.
  • AC7. Идемпотентность и аудит. Дано мутация с Idempotency-Key. Когда она повторяется по таймауту. Тогда возвращается тот же результат без второго применения; исходная мутация — в глубоком аудите.
  • AC8. Конкурентная правка. Дано два админа правят один пакет. Когда оба присылают ExpectedVersion. Тогда первая фиксация побеждает, вторая получает CONCURRENCY_CONFLICT (409) и перечитывает Version.
  • AC9. Никаких событий. Дано любая мутация пакета. Когда она зафиксирована. Тогда pin.promotion.purchase-changed.v1 не публикуется и потребители буста не затронуты.

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

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

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