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

Entity / Aggregate: PromotionPurchase

Дом полей покупки пакета продвижения и её полиморфных целей (контекст promotion). Проекции (модели ответов developers API, event payload) ссылаются сюда и перечисляют только дельту.

Назначение

PromotionPurchase — покупка платформенного пакета продвижения (promotion-package.md) компанией (застройщик/агентство/брокер — ADR-0052). Агрегат владеет коллекцией целей PromotionPurchaseTarget (1..N) и снапшотит PackageKind/BoostWeight/Price на момент покупки — правки/архив пакета не трогают действующую покупку (INV-2 SnapshotImmutable).

По решению №10 (technical-assignment.md) оплата отложена: покупка активируется сразу без оплаты (PaymentStatus = NotRequired); статус Pending и поля цены заложены под будущий эквайринг. Активная покупка публикует событие PromotionPurchaseChangedKafkaEvent, а денормализацию буста в целевые карточки выполняют потребители (catalog — карточки проекта/объекта, newsPost.PromotionBoost), не сам промо-контекст (ADR-0060).

Классификация

ПолеЗначение
Контекстpromotion
KindAggregateRoot (PromotionPurchaseTarget — дочерняя Entity внутри агрегата)
Source of truthэтот контекст
Таблицыpromotion.promotion_purchases, promotion.promotion_purchase_targets
Tenant isolationTenantId = CompanyId (ADR-0052; обобщение DeveloperId → CompanyId)

Поля PromotionPurchase

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор покупки (= Kafka key события).
TenantIdGuidДаindex ix_promotion_purchases_tenant_id; = CompanyIdidentity (актор)Компания-покупатель (ADR-0052).
PackageIdGuidДаFK fk_promotion_purchases_package → promotion_packages.id (restrict)developerКупленный пакет.
PackageCodestringДаvarchar(64)snapshot пакетаСтабильный код пакета (для аналитики потребителей без lookup).
PackageKindPromotionPackageKindДаvarchar(16)snapshot пакетаСнапшот вида (INV-2).
BoostWeightintДа1..100snapshot пакетаСнапшот веса — правки пакета не меняют покупку (INV-2).
PricePromotionPrice (owned)Дасм. promotion-package.mdsnapshot пакетаСнапшот цены для будущего биллинга.
PaymentStatusPromotionPaymentStatusДаvarchar(16), default NotRequiredappСейчас всегда NotRequired (оплата отложена — решение №10); Pending/Paid/Refunded — заготовка эквайринга.
StartsAtDateTimeOffsetДаapp (= момент активации)Начало действия.
EndsAtDateTimeOffsetДа> StartsAt (check); index ix_promotion_purchases_ends_at (status, ends_at) where status = 'Active'app (StartsAt + DurationDays)Конец действия — job переводит в Expired.
StatusPromotionPurchaseStatusДаvarchar(16), indexappСм. stateDiagram.
CancelReasonstring?Нетvarchar(512); обязателен при Status=Cancelled (check)developer/adminПричина отмены.
CreatedAt / CreatedByUserId / UpdatedAt / UpdatedByUserIdстандартappАудит (без soft-delete — терминальные статусы).
VersionlongДа>= 1appДоменная версия; монотонный guard у потребителей.

Enum PromotionPurchaseStatus

ЗначениеОписание
PendingСоздана, ожидает активации. В режиме без оплаты — мгновенный переход в Active в той же операции; статус существует для будущего платёжного flow.
ActiveДействует: BoostWeight учитывается потребителями буста.
ExpiredСрок истёк (job expire-promotions); вклад снят. Терминальный.
CancelledОтменена покупателем или админом до истечения; вклад снят. Терминальный.

Enum PromotionPaymentStatus

ЗначениеОписание
NotRequiredТекущий режим платформы: оплата не требуется (решение №10).
Pending / Paid / RefundedБудущий эквайринг; сейчас не присваиваются.

Поля PromotionPurchaseTarget (дочерняя Entity агрегата PromotionPurchase)

Полиморфная цель продвижения. Заменяет прежние TargetProjectId + PromotionPurchaseLot (ADR-0060).

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор строки.
PurchaseIdGuidДаFK fk_promotion_purchase_targets_purchase → promotion_purchases.id (cascade); unique ux_promotion_purchase_targets (purchase_id, target_kind, target_id)appРодительская покупка.
TargetPromotionTarget (owned)Дасм. VO; index ix_promotion_purchase_targets_target (target_kind, target_id)developerПолиморфная цель продвижения.

Value objects

PromotionTarget (owned; в PromotionPurchaseTarget)

ПолеТипОбязательностьОграниченияКолонка
TargetKindPromotionTargetKindДаvarchar(16)target_kind
TargetIdGuidДа!= Guid.Empty; логическая ссылка (без FK — цель в чужом контексте)target_id

Enum PromotionTargetKind

ЗначениеВладелец целиConsumer буста
Projectcatalog (CatalogProjectCard)catalog
PropertyObjectcatalog (CatalogPropertyObjectCard)catalog
Postnews (Post)news

PromotionPrice (owned; snapshot)

Дом — promotion-package.md (VO PromotionPrice). Снапшотится в покупку.

Модели JSONB

JSONB-модели не используются.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
PromotionPackagePromotionPurchasereferenceFK package_idчтение пакета при покупке (снапшот)restrict
PromotionPurchasePromotionPurchaseTargetowned-коллекция агрегатаFK purchase_idзагружаются вместе с покупкойcascade
PromotionPurchaseTarget → (Project/PropertyObject/Post)reference (логическая, без FK)Target.TargetIdнет (цель в чужом контексте)нет — потребитель снимает вклад по событию

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-1 TargetsMatchKindНабор TargetKind целей допустим для PackageKind (таблица «Kind → TargetKind» в promotion-package.md); ProjectBoost — ровно 1 цель, PropertyObjectBoost/PostBoost1..MaxTargetsPerPurchase; цели distinctпроверка при создании покупкиPROMOTION_TARGETS_INVALID
INV-2 SnapshotImmutableBoostWeight/Price/PackageKind/PackageCode фиксируются при создании и не пересчитываются при правке пакетаполя неизменяемы после созданиянет
INV-3 PackagePurchasableПакет Status=Active на момент покупкипроверка при покупкеPROMOTION_PACKAGE_NOT_AVAILABLE
INV-4 TargetOwnershipКаждый TargetId принадлежит компании-покупателю (TenantId) — валидируется в контексте-владельце цели (purchase-promotion.md, fail-closed)проверка при покупкеPROMOTION_TARGET_NOT_OWNED
INV-5 NoOverlapSameTargetНа (TenantId, PackageId, Target) — не более одной покупки в статусах Pending/Active (защита от задвоения буста)проверка перед созданиемPROMOTION_ALREADY_ACTIVE
INV-6 TerminalFrozenИз Expired/Cancelled переходов нетпроверка переходаPROMOTION_INVALID_STATUS_TRANSITION

Переходы статусов (из / в, условие, инициатор, эффект, событие)

Каждый переход (кроме создания [*] → Pending) публикует PromotionPurchaseChangedKafkaEvent в одном согласованном изменении со сменой статуса. [*] → Pending в режиме без оплаты (решение №10) существует лишь как промежуточная точка внутри операции покупки — самостоятельного события не даёт (первое публикуемое состояние — Active).

ИзВУсловиеИнициаторЭффектСобытие (Status)
[*]Pendingсоздание покупки, пройдены INV-1 TargetsMatchKind, INV-3 PackagePurchasable, INV-4 TargetOwnership, INV-5 NoOverlapSameTargetменеджер компании (purchase)снапшот PackageKind/PackageCode/BoostWeight/Price (INV-2), цели зафиксированы; вклад ещё не начислен— (промежуточный, без оплаты)
PendingActiveрежим без оплаты — в той же операции покупки; будущий эквайринг — после PaymentStatus=Paidменеджер компании (purchase) / (будущее) billing по pin.promotion.purchase-changedStartsAt = now, EndsAt = now + DurationDays; потребители поднимают буст целейActive
PendingCancelledотмена до активациименеджер (cancel) / администратор (admin-cancel)CancelReason задан; вклад не начислялся (снимать нечего)Cancelled
ActiveExpirednow() >= EndsAtSystem (job expire-promotions)вклад снимают потребители по событию; переход терминаленExpired
ActiveCancelledдосрочная отменаменеджер (cancel) / администратор (admin-cancel)CancelReason задан; потребители снимают вкладCancelled
Expired / Cancelledлюбойтерминально (INV-6 TerminalFrozen)отказ PROMOTION_INVALID_STATUS_TRANSITION; в выборку job-а истечения не попадают

Конкурентность отмены и истечения. Гонка Active → Cancelled (менеджер/админ) ↔ Active → Expired (job) разрешается по версии покупки: побеждает зафиксировавший первым; проигравший видит покупку уже в терминальном статусе (CONCURRENCY_CONFLICT в API-отмене — retry покажет Expired; в job-е строка уже Cancelled и в выборку не попадает). По покупке публикуется ровно одно терминальное событие.

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
PromotionPurchaseChangedKafkaEventинтеграционное (ContractVersion = 1, topic pin.promotion.purchase-changed.v1, key = Id)Каждый переход статуса покупкиPurchaseId, TenantId, PackageId, PackageCode, PackageKind, Targets[] {TargetKind, TargetId}, Status, PaymentStatus, BoostWeight, StartsAt, EndsAt, Price {Amount, CurrencyId, Currency: CurrencySnapshot}, Version — дом ../events/promotion-purchase-changed.md

Интеграционное событие публикуется в одном согласованном изменении со сменой статуса покупки. Отдельных доменных событий пересчёта буста нет — денормализация вынесена к потребителям (ADR-0060), промо-контекст только публикует контракт.

Индексы, хранение, безопасность

АспектЗначение
Индексыпокупки: PK, ix_promotion_purchases_tenant_id, ix_promotion_purchases_ends_at (status, ends_at) where status = 'Active' (выборка job-а); цели: ux_promotion_purchase_targets (purchase_id, target_kind, target_id), ix_promotion_purchase_targets_target (target_kind, target_id)
Партиционирование / retentionnone; покупки — бессрочно (финансовая история будущего биллинга)
Concurrencyоптимистическая блокировка; job expire-promotions выбирает кандидатов с блокировкой строк (FOR UPDATE SKIP LOCKED), идемпотентен (терминальные статусы не выбираются) — ADR-0056 п.3
Permissions / PIIпокупка/отмена — promotion.purchases.manage в scope организации; чтение своих — promotion.purchases.view; изоляция по TenantId. PII нет; Price — не секрет
Auditотметка автора + Version; покупки не удаляются; переходы — публикуется событие

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

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

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