@gravito/satellite-marketing 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.dockerignore +8 -0
  2. package/.env.example +19 -0
  3. package/ARCHITECTURE.md +22 -0
  4. package/CHANGELOG.md +10 -0
  5. package/Dockerfile +25 -0
  6. package/README.md +24 -0
  7. package/WHITEPAPER.md +29 -0
  8. package/dist/index.d.ts +9 -0
  9. package/dist/index.js +379 -0
  10. package/package.json +32 -0
  11. package/src/Application/Rules/BuyXGetYRule.ts +40 -0
  12. package/src/Application/Rules/CartThresholdRule.ts +21 -0
  13. package/src/Application/Rules/CategoryDiscountRule.ts +40 -0
  14. package/src/Application/Rules/FreeShippingRule.ts +29 -0
  15. package/src/Application/Rules/MembershipLevelRule.ts +28 -0
  16. package/src/Application/Services/CouponService.ts +57 -0
  17. package/src/Application/Services/PromotionEngine.ts +65 -0
  18. package/src/Application/UseCases/AdminListCoupons.ts +31 -0
  19. package/src/Application/UseCases/CreateMarketing.ts +22 -0
  20. package/src/Domain/Contracts/IMarketingRepository.ts +6 -0
  21. package/src/Domain/Contracts/IPromotionRule.ts +14 -0
  22. package/src/Domain/Entities/Coupon.ts +44 -0
  23. package/src/Domain/Entities/Marketing.ts +26 -0
  24. package/src/Domain/PromotionRules/BuyXGetYRule.ts +8 -0
  25. package/src/Domain/PromotionRules/FixedAmountDiscountRule.ts +8 -0
  26. package/src/Domain/PromotionRules/FreeShippingRule.ts +8 -0
  27. package/src/Domain/PromotionRules/PercentageDiscountRule.ts +8 -0
  28. package/src/Infrastructure/Persistence/AtlasMarketingRepository.ts +24 -0
  29. package/src/Infrastructure/Persistence/Migrations/20250101_create_marketing_tables.ts +47 -0
  30. package/src/Interface/Http/Controllers/AdminMarketingController.ts +24 -0
  31. package/src/index.ts +82 -0
  32. package/src/manifest.json +12 -0
  33. package/tests/advanced-rules.test.ts +105 -0
  34. package/tests/grand-review.ts +95 -0
  35. package/tests/unit.test.ts +120 -0
  36. package/tsconfig.json +26 -0
@@ -0,0 +1,29 @@
1
+ import type { IPromotionRule, MarketingAdjustment } from '../../Domain/Contracts/IPromotionRule'
2
+
3
+ export class FreeShippingRule implements IPromotionRule {
4
+ /**
5
+ * config: { min_amount: 1000 }
6
+ * 意即:滿 1000 元即享免運
7
+ */
8
+ match(order: any, config: any): MarketingAdjustment | null {
9
+ const subtotal = Number(order.subtotalAmount)
10
+
11
+ // 如果滿足門檻
12
+ if (subtotal >= config.min_amount) {
13
+ // 這裡我們需要知道當前的運費是多少。
14
+ // 在 Gravito Commerce 中,運費被視為一個 Adjustment。
15
+ // 我們的策略是:回傳一個與運費金額相等的負值折扣。
16
+
17
+ // 模擬邏輯:直接查現有的調整項 (假設我們知道 standard 運費是 60)
18
+ // 在更進階的實作中,我們可以遍歷 order.adjustments 尋找 sourceType: 'shipping'
19
+ return {
20
+ label: `Free Shipping (Orders over ${config.min_amount})`,
21
+ amount: -60, // 抵銷基礎運費
22
+ sourceType: 'promotion',
23
+ sourceId: 'free_shipping',
24
+ }
25
+ }
26
+
27
+ return null
28
+ }
29
+ }
@@ -0,0 +1,28 @@
1
+ import { DB } from '@gravito/atlas'
2
+ import type { IPromotionRule, MarketingAdjustment } from '../../Domain/Contracts/IPromotionRule'
3
+
4
+ export class MembershipLevelRule implements IPromotionRule {
5
+ /**
6
+ * config: { target_level: 'gold', discount_percent: 10 }
7
+ */
8
+ async match(order: any, config: any): Promise<MarketingAdjustment | null> {
9
+ if (!order.memberId) {
10
+ return null
11
+ }
12
+
13
+ // 從 Membership 表中查詢該會員的等級 (跨衛星數據讀取)
14
+ const member = (await DB.table('members').where('id', order.memberId).first()) as any
15
+
16
+ if (member && member.level === config.target_level) {
17
+ const discount = Number(order.subtotalAmount) * (config.discount_percent / 100)
18
+ return {
19
+ label: `VIP Discount: ${config.target_level.toUpperCase()} Member ${config.discount_percent}% Off`,
20
+ amount: -discount,
21
+ sourceType: 'promotion',
22
+ sourceId: 'membership_level',
23
+ }
24
+ }
25
+
26
+ return null
27
+ }
28
+ }
@@ -0,0 +1,57 @@
1
+ import { DB } from '@gravito/atlas'
2
+ import type { PlanetCore } from '@gravito/core'
3
+
4
+ export class CouponService {
5
+ constructor(private core: PlanetCore) {}
6
+
7
+ /**
8
+ * 根據代碼查找折價券
9
+ */
10
+ async findByCode(code: string): Promise<any> {
11
+ this.core.logger.info(`[CouponService] Looking up coupon: ${code}`)
12
+ return DB.table('coupons').where('code', code).first()
13
+ }
14
+
15
+ /**
16
+ * 計算折價券調整金額
17
+ */
18
+ async getAdjustment(code: string, _order: any): Promise<any> {
19
+ this.core.logger.info(`[CouponService] Calculating adjustment for: ${code}`)
20
+ const coupon = await this.findByCode(code)
21
+
22
+ if (!coupon) {
23
+ throw new Error('coupon_not_found')
24
+ }
25
+
26
+ if (coupon.is_active === false) {
27
+ throw new Error('inactive')
28
+ }
29
+
30
+ if (coupon.expires_at) {
31
+ const expiresAt = new Date(coupon.expires_at)
32
+ if (!Number.isNaN(expiresAt.getTime()) && expiresAt < new Date()) {
33
+ throw new Error('expired')
34
+ }
35
+ }
36
+
37
+ const value = Number(coupon.value)
38
+ const subtotal = Number(_order?.subtotalAmount ?? 0)
39
+
40
+ let amount = 0
41
+ const type = String(coupon.type || '').toLowerCase()
42
+ if (type === 'fixed') {
43
+ amount = -value
44
+ } else if (type === 'percent' || type === 'percentage') {
45
+ amount = -(subtotal * (value / 100))
46
+ } else {
47
+ throw new Error('unsupported_coupon_type')
48
+ }
49
+
50
+ return {
51
+ label: `Coupon: ${coupon.name}`,
52
+ amount,
53
+ sourceType: 'coupon',
54
+ sourceId: coupon.id ?? coupon.code,
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,65 @@
1
+ import { DB } from '@gravito/atlas'
2
+ import type { PlanetCore } from '@gravito/core'
3
+ import type { IPromotionRule, MarketingAdjustment } from '../../Domain/Contracts/IPromotionRule'
4
+ import { BuyXGetYRule } from '../Rules/BuyXGetYRule'
5
+ import { CartThresholdRule } from '../Rules/CartThresholdRule'
6
+ import { CategoryDiscountRule } from '../Rules/CategoryDiscountRule'
7
+ import { FreeShippingRule } from '../Rules/FreeShippingRule'
8
+ import { MembershipLevelRule } from '../Rules/MembershipLevelRule'
9
+
10
+ export class PromotionEngine {
11
+ constructor(private core: PlanetCore) {}
12
+
13
+ private ruleFor(type: string): IPromotionRule | null {
14
+ switch (type) {
15
+ case 'cart_threshold':
16
+ return new CartThresholdRule()
17
+ case 'buy_x_get_y':
18
+ return new BuyXGetYRule()
19
+ case 'category_discount':
20
+ return new CategoryDiscountRule()
21
+ case 'free_shipping':
22
+ return new FreeShippingRule()
23
+ case 'membership_level':
24
+ return new MembershipLevelRule()
25
+ default:
26
+ return null
27
+ }
28
+ }
29
+
30
+ async applyPromotions(order: any): Promise<MarketingAdjustment[]> {
31
+ this.core.logger.info('[PromotionEngine] Calculating promotions...')
32
+ const applied: MarketingAdjustment[] = []
33
+
34
+ const promotions = (await DB.table('promotions')
35
+ .where('is_active', true)
36
+ .orderBy('priority', 'desc')
37
+ .get()) as any[]
38
+
39
+ for (const promo of promotions) {
40
+ const rule = this.ruleFor(String(promo.type || '').toLowerCase())
41
+ if (!rule) {
42
+ continue
43
+ }
44
+
45
+ const rawConfig = promo.configuration ?? '{}'
46
+ let config = {}
47
+ if (typeof rawConfig === 'string') {
48
+ try {
49
+ config = JSON.parse(rawConfig)
50
+ } catch {
51
+ config = {}
52
+ }
53
+ } else if (rawConfig && typeof rawConfig === 'object') {
54
+ config = rawConfig
55
+ }
56
+
57
+ const result = await rule.match(order, config)
58
+ if (result) {
59
+ applied.push(result)
60
+ }
61
+ }
62
+
63
+ return applied
64
+ }
65
+ }
@@ -0,0 +1,31 @@
1
+ import { UseCase } from '@gravito/enterprise'
2
+ import { Coupon } from '../../Domain/Entities/Coupon'
3
+
4
+ export class AdminListCoupons extends UseCase<void, Coupon[]> {
5
+ async execute(): Promise<Coupon[]> {
6
+ // 模擬從資料庫讀取優惠券
7
+ return [
8
+ Coupon.create({
9
+ code: 'WELCOME2025',
10
+ name: '新年歡迎禮',
11
+ type: 'PERCENTAGE',
12
+ value: 10,
13
+ minPurchase: 0,
14
+ startsAt: new Date('2025-01-01'),
15
+ expiresAt: new Date('2025-12-31'),
16
+ usageLimit: 1000,
17
+ status: 'ACTIVE',
18
+ }),
19
+ Coupon.create({
20
+ code: 'SAVE500',
21
+ name: '滿額折抵',
22
+ type: 'FIXED',
23
+ value: 500,
24
+ minPurchase: 5000,
25
+ startsAt: new Date('2025-01-01'),
26
+ expiresAt: new Date('2025-06-30'),
27
+ status: 'ACTIVE',
28
+ }),
29
+ ]
30
+ }
31
+ }
@@ -0,0 +1,22 @@
1
+ import { UseCase } from '@gravito/enterprise'
2
+ import type { IMarketingRepository } from '../../Domain/Contracts/IMarketingRepository'
3
+ import { Marketing } from '../../Domain/Entities/Marketing'
4
+
5
+ export interface CreateMarketingInput {
6
+ name: string
7
+ }
8
+
9
+ export class CreateMarketing extends UseCase<CreateMarketingInput, string> {
10
+ constructor(private repository: IMarketingRepository) {
11
+ super()
12
+ }
13
+
14
+ async execute(input: CreateMarketingInput): Promise<string> {
15
+ const id = crypto.randomUUID()
16
+ const entity = Marketing.create(id, input.name)
17
+
18
+ await this.repository.save(entity)
19
+
20
+ return id
21
+ }
22
+ }
@@ -0,0 +1,6 @@
1
+ import type { Repository } from '@gravito/enterprise'
2
+ import type { Marketing } from '../Entities/Marketing'
3
+
4
+ export interface IMarketingRepository extends Repository<Marketing, string> {
5
+ // Add custom methods here
6
+ }
@@ -0,0 +1,14 @@
1
+ export interface MarketingAdjustment {
2
+ label: string
3
+ amount: number
4
+ sourceType: string
5
+ sourceId: string
6
+ }
7
+
8
+ export interface IPromotionRule {
9
+ /**
10
+ * 檢查訂單是否符合此規則
11
+ * 支援 async 以便執行跨衛星的 DB 查詢
12
+ */
13
+ match(order: any, config: any): MarketingAdjustment | null | Promise<MarketingAdjustment | null>
14
+ }
@@ -0,0 +1,44 @@
1
+ import { Entity } from '@gravito/enterprise'
2
+
3
+ export interface CouponProps {
4
+ code: string
5
+ name: string
6
+ type: 'FIXED' | 'PERCENTAGE'
7
+ value: number
8
+ minPurchase: number
9
+ startsAt: Date
10
+ expiresAt: Date
11
+ usageLimit?: number
12
+ usedCount: number
13
+ status: 'ACTIVE' | 'EXPIRED' | 'DISABLED'
14
+ }
15
+
16
+ export class Coupon extends Entity<string> {
17
+ private _props: CouponProps
18
+
19
+ constructor(props: CouponProps, id?: string) {
20
+ super(id || crypto.randomUUID())
21
+ this._props = props
22
+ }
23
+
24
+ static create(props: Omit<CouponProps, 'usedCount'>, id?: string): Coupon {
25
+ return new Coupon(
26
+ {
27
+ ...props,
28
+ usedCount: 0,
29
+ },
30
+ id
31
+ )
32
+ }
33
+
34
+ get code() {
35
+ return this._props.code
36
+ }
37
+ get status() {
38
+ return this._props.status
39
+ }
40
+
41
+ unpack(): CouponProps {
42
+ return { ...this._props }
43
+ }
44
+ }
@@ -0,0 +1,26 @@
1
+ import { Entity } from '@gravito/enterprise'
2
+
3
+ export interface MarketingProps {
4
+ name: string
5
+ createdAt: Date
6
+ }
7
+
8
+ export class Marketing extends Entity<string> {
9
+ constructor(
10
+ id: string,
11
+ private props: MarketingProps
12
+ ) {
13
+ super(id)
14
+ }
15
+
16
+ static create(id: string, name: string): Marketing {
17
+ return new Marketing(id, {
18
+ name,
19
+ createdAt: new Date(),
20
+ })
21
+ }
22
+
23
+ get name() {
24
+ return this.props.name
25
+ }
26
+ }
@@ -0,0 +1,8 @@
1
+ import type { IPromotionRule, MarketingAdjustment } from '../Contracts/IPromotionRule'
2
+
3
+ export class BuyXGetYRule implements IPromotionRule {
4
+ name = 'buy_x_get_y'
5
+ match(_order: any, _config: any): MarketingAdjustment | null {
6
+ return null // 預設不匹配
7
+ }
8
+ }
@@ -0,0 +1,8 @@
1
+ import type { IPromotionRule, MarketingAdjustment } from '../Contracts/IPromotionRule'
2
+
3
+ export class FixedAmountDiscountRule implements IPromotionRule {
4
+ name = 'fixed_discount'
5
+ match(_order: any, _config: any): MarketingAdjustment | null {
6
+ return null
7
+ }
8
+ }
@@ -0,0 +1,8 @@
1
+ import type { IPromotionRule, MarketingAdjustment } from '../Contracts/IPromotionRule'
2
+
3
+ export class FreeShippingRule implements IPromotionRule {
4
+ name = 'free_shipping'
5
+ match(_order: any, _config: any): MarketingAdjustment | null {
6
+ return null
7
+ }
8
+ }
@@ -0,0 +1,8 @@
1
+ import type { IPromotionRule, MarketingAdjustment } from '../Contracts/IPromotionRule'
2
+
3
+ export class PercentageDiscountRule implements IPromotionRule {
4
+ name = 'percentage_discount'
5
+ match(_order: any, _config: any): MarketingAdjustment | null {
6
+ return null
7
+ }
8
+ }
@@ -0,0 +1,24 @@
1
+ import type { IMarketingRepository } from '../../Domain/Contracts/IMarketingRepository'
2
+ import type { Marketing } from '../../Domain/Entities/Marketing'
3
+
4
+ export class AtlasMarketingRepository implements IMarketingRepository {
5
+ async save(entity: Marketing): Promise<void> {
6
+ // Dogfooding: Use @gravito/atlas for persistence
7
+ console.log('[Atlas] Saving entity:', entity.id)
8
+ // await DB.table('marketings').insert({ ... })
9
+ }
10
+
11
+ async findById(_id: string): Promise<Marketing | null> {
12
+ return null
13
+ }
14
+
15
+ async findAll(): Promise<Marketing[]> {
16
+ return []
17
+ }
18
+
19
+ async delete(_id: string): Promise<void> {}
20
+
21
+ async exists(_id: string): Promise<boolean> {
22
+ return false
23
+ }
24
+ }
@@ -0,0 +1,47 @@
1
+ import { type Blueprint, Schema } from '@gravito/atlas'
2
+
3
+ export default {
4
+ async up() {
5
+ // 1. Promotions (System-wide rules)
6
+ await Schema.create('promotions', (table: Blueprint) => {
7
+ table.string('id').primary()
8
+ table.string('name')
9
+ table.string('type') // e.g., "cart_threshold", "buy_x_get_y", "category_discount"
10
+ table.text('configuration') // JSON: Store rules like { min_amount: 2000, discount: 200 }
11
+ table.integer('priority').default(0)
12
+ table.boolean('is_active').default(true)
13
+ table.timestamp('starts_at').nullable()
14
+ table.timestamp('ends_at').nullable()
15
+ table.timestamp('created_at').default('CURRENT_TIMESTAMP')
16
+ })
17
+
18
+ // 2. Coupons (User-entered codes)
19
+ await Schema.create('coupons', (table: Blueprint) => {
20
+ table.string('id').primary()
21
+ table.string('code').unique()
22
+ table.string('name')
23
+ table.string('type') // e.g., "fixed", "percent"
24
+ table.decimal('value', 15, 2)
25
+ table.text('configuration').nullable() // JSON: Constraints like { min_spend: 1000 }
26
+ table.integer('usage_limit').nullable()
27
+ table.integer('usage_count').default(0)
28
+ table.boolean('is_active').default(true)
29
+ table.timestamp('expires_at').nullable()
30
+ })
31
+
32
+ // 3. Coupon Usage Tracking
33
+ await Schema.create('coupon_usages', (table: Blueprint) => {
34
+ table.string('id').primary()
35
+ table.string('coupon_id')
36
+ table.string('member_id')
37
+ table.string('order_id')
38
+ table.timestamp('used_at').default('CURRENT_TIMESTAMP')
39
+ })
40
+ },
41
+
42
+ async down() {
43
+ await Schema.dropIfExists('coupon_usages')
44
+ await Schema.dropIfExists('coupons')
45
+ await Schema.dropIfExists('promotions')
46
+ },
47
+ }
@@ -0,0 +1,24 @@
1
+ import type { PlanetCore } from '@gravito/core'
2
+ import type { AdminListCoupons } from '../../../Application/UseCases/AdminListCoupons'
3
+ import type { Coupon } from '../../../Domain/Entities/Coupon'
4
+
5
+ export class AdminMarketingController {
6
+ constructor(private core: PlanetCore) {}
7
+
8
+ async coupons(ctx: any) {
9
+ try {
10
+ const useCase = this.core.container.make<AdminListCoupons>(
11
+ 'marketing.usecase.adminListCoupons'
12
+ )
13
+ const coupons = await useCase.execute()
14
+ return ctx.json(
15
+ coupons.map((c: Coupon) => ({
16
+ id: c.id,
17
+ ...c.unpack(),
18
+ }))
19
+ )
20
+ } catch (error: any) {
21
+ return ctx.json({ message: error.message }, 500)
22
+ }
23
+ }
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { type Container, ServiceProvider } from '@gravito/core'
4
+ import { CouponService } from './Application/Services/CouponService'
5
+ import { PromotionEngine } from './Application/Services/PromotionEngine'
6
+ import { AdminListCoupons } from './Application/UseCases/AdminListCoupons'
7
+ import { AdminMarketingController } from './Interface/Http/Controllers/AdminMarketingController'
8
+
9
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
10
+
11
+ export class MarketingServiceProvider extends ServiceProvider {
12
+ register(container: Container): void {
13
+ container.singleton('marketing.promotion-engine', () => {
14
+ return new PromotionEngine(this.core!)
15
+ })
16
+ container.singleton('marketing.coupon-service', () => {
17
+ return new CouponService(this.core!)
18
+ })
19
+ container.bind('marketing.usecase.adminListCoupons', () => new AdminListCoupons())
20
+ container.singleton(
21
+ 'marketing.controller.admin',
22
+ () => new AdminMarketingController(this.core!)
23
+ )
24
+ }
25
+
26
+ getMigrationsPath(): string {
27
+ return join(__dirname, 'Infrastructure/Persistence/Migrations')
28
+ }
29
+
30
+ override async boot(): Promise<void> {
31
+ const core = this.core
32
+ if (!core) {
33
+ return
34
+ }
35
+
36
+ const adminCtrl = core.container.make<AdminMarketingController>('marketing.controller.admin')
37
+
38
+ // 管理端路由
39
+ core.router.prefix('/api/admin/v1/marketing').group((router) => {
40
+ router.get('/coupons', (ctx) => adminCtrl.coupons(ctx))
41
+ })
42
+
43
+ const promoEngine = core.container.make<PromotionEngine>('marketing.promotion-engine')
44
+ const couponService = core.container.make<CouponService>('marketing.coupon-service')
45
+
46
+ // 1. 價格調整 Filter (Promotion + Coupon)
47
+ core.hooks.addFilter(
48
+ 'commerce:order:adjustments',
49
+ async (adjustments: any[], { order, extras }: any) => {
50
+ core.logger.info(`🎯 [Marketing] 正在為訂單 ${order.id} 掃描促銷與折價券...`)
51
+
52
+ const results = [...adjustments]
53
+
54
+ // 自動套用促銷活動
55
+ const promoAdjustments = await promoEngine.applyPromotions(order)
56
+ results.push(...promoAdjustments)
57
+
58
+ // 手動套用折價券 (從下單請求的 extras 中獲取 couponCode)
59
+ if (extras?.couponCode) {
60
+ try {
61
+ const couponAdj = await couponService.getAdjustment(extras.couponCode, order)
62
+ if (couponAdj) {
63
+ results.push(couponAdj)
64
+ }
65
+ } catch (e: any) {
66
+ core.logger.warn(`⚠️ [Marketing] 折價券無效: ${e.message}`)
67
+ // 注意:這裡我們不拋出錯誤,讓下單繼續但沒有折扣
68
+ }
69
+ }
70
+
71
+ return results
72
+ }
73
+ )
74
+
75
+ // 2. 訂單完成後的核銷動作
76
+ core.hooks.addAction('commerce:order-placed', async (payload: any) => {
77
+ core.logger.info(`📝 [Marketing] 訂單 ${payload.orderId} 已建立,正在處理折價券核銷...`)
78
+ })
79
+
80
+ core.logger.info('🛰️ Satellite Marketing is operational')
81
+ }
82
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "id": "satellite-marketing",
3
+ "name": "Marketing & Promotions",
4
+ "version": "0.1.0",
5
+ "description": "Flexible rule engine for promotions, coupons, and discounts.",
6
+ "author": "Gravito Team",
7
+ "type": "satellite",
8
+ "hooks": {
9
+ "actions": ["commerce:order-placed"],
10
+ "filters": ["commerce:order:adjustments"]
11
+ }
12
+ }
@@ -0,0 +1,105 @@
1
+ import { beforeEach, describe, expect, it } from 'bun:test'
2
+ import { DB, Schema } from '@gravito/atlas'
3
+ import { PlanetCore } from '@gravito/core'
4
+ import type { PromotionEngine } from '../src/Application/Services/PromotionEngine'
5
+ import { MarketingServiceProvider } from '../src/index'
6
+
7
+ describe('Marketing Satellite - Advanced Rules', () => {
8
+ let core: PlanetCore
9
+ let engine: PromotionEngine
10
+
11
+ beforeEach(async () => {
12
+ core = await PlanetCore.boot({
13
+ config: {
14
+ 'database.default': 'sqlite',
15
+ 'database.connections.sqlite': { driver: 'sqlite', database: ':memory:' },
16
+ },
17
+ })
18
+ DB.addConnection('default', { driver: 'sqlite', database: ':memory:' })
19
+
20
+ // 1. 模擬基礎表
21
+ await Schema.dropIfExists('promotions')
22
+ await Schema.create('promotions', (table) => {
23
+ table.string('id').primary()
24
+ table.string('name')
25
+ table.string('type')
26
+ table.text('configuration')
27
+ table.integer('priority').default(0)
28
+ table.boolean('is_active').default(true)
29
+ })
30
+
31
+ await Schema.dropIfExists('members')
32
+ await Schema.create('members', (table) => {
33
+ table.string('id').primary()
34
+ table.string('level')
35
+ })
36
+
37
+ await core.use(new MarketingServiceProvider())
38
+ await core.bootstrap()
39
+ engine = core.container.make('marketing.promotion-engine')
40
+ })
41
+
42
+ it('應該能正確計算 VIP 金級會員折扣', async () => {
43
+ // 注入金級會員
44
+ await DB.table('members').insert({ id: 'm_gold', level: 'gold' })
45
+
46
+ // 注入規則:金級會員 9 折 (10% off)
47
+ await DB.table('promotions').insert({
48
+ id: 'p_vip',
49
+ name: 'Gold Member 10% Off',
50
+ type: 'membership_level',
51
+ configuration: JSON.stringify({ target_level: 'gold', discount_percent: 10 }),
52
+ is_active: true,
53
+ })
54
+
55
+ const order = { id: 'o1', memberId: 'm_gold', subtotalAmount: 5000 }
56
+ const adjustments = await engine.applyPromotions(order)
57
+
58
+ expect(adjustments).toHaveLength(1)
59
+ expect(adjustments[0].amount).toBe(-500) // 5000 * 0.1
60
+ expect(adjustments[0].label).toContain('GOLD Member')
61
+ })
62
+
63
+ it('應該能正確計算全分類折扣 (Electronics)', async () => {
64
+ await DB.table('promotions').insert({
65
+ id: 'p_cat',
66
+ name: 'Electronics 20% Sale',
67
+ type: 'category_discount',
68
+ configuration: JSON.stringify({ category_id: 'electronics', discount_percent: 20 }),
69
+ is_active: true,
70
+ })
71
+
72
+ const order = {
73
+ subtotalAmount: 10000,
74
+ items: [
75
+ // 商品 A 屬於電子類 (路徑包含 /electronics/)
76
+ { props: { totalPrice: 8000, options: { categoryPath: '/1/electronics/42/' } } },
77
+ // 商品 B 屬於食品類
78
+ { props: { totalPrice: 2000, options: { categoryPath: '/2/food/' } } },
79
+ ],
80
+ }
81
+
82
+ const adjustments = await engine.applyPromotions(order)
83
+
84
+ expect(adjustments).toHaveLength(1)
85
+ expect(adjustments[0].amount).toBe(-1600) // 8000 * 0.2
86
+ expect(adjustments[0].label).toContain('ELECTRONICS')
87
+ })
88
+
89
+ it('應該能正確套用滿額免運折扣', async () => {
90
+ await DB.table('promotions').insert({
91
+ id: 'p_ship',
92
+ name: 'Free Shipping over 1000',
93
+ type: 'free_shipping',
94
+ configuration: JSON.stringify({ min_amount: 1000 }),
95
+ is_active: true,
96
+ })
97
+
98
+ const order = { subtotalAmount: 1500 }
99
+ const adjustments = await engine.applyPromotions(order)
100
+
101
+ expect(adjustments).toHaveLength(1)
102
+ expect(adjustments[0].amount).toBe(-60) // 抵銷 60 元運費
103
+ expect(adjustments[0].label).toContain('Free Shipping')
104
+ })
105
+ })