@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.
- package/.dockerignore +8 -0
- package/.env.example +19 -0
- package/ARCHITECTURE.md +22 -0
- package/CHANGELOG.md +10 -0
- package/Dockerfile +25 -0
- package/README.md +24 -0
- package/WHITEPAPER.md +29 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +379 -0
- package/package.json +32 -0
- package/src/Application/Rules/BuyXGetYRule.ts +40 -0
- package/src/Application/Rules/CartThresholdRule.ts +21 -0
- package/src/Application/Rules/CategoryDiscountRule.ts +40 -0
- package/src/Application/Rules/FreeShippingRule.ts +29 -0
- package/src/Application/Rules/MembershipLevelRule.ts +28 -0
- package/src/Application/Services/CouponService.ts +57 -0
- package/src/Application/Services/PromotionEngine.ts +65 -0
- package/src/Application/UseCases/AdminListCoupons.ts +31 -0
- package/src/Application/UseCases/CreateMarketing.ts +22 -0
- package/src/Domain/Contracts/IMarketingRepository.ts +6 -0
- package/src/Domain/Contracts/IPromotionRule.ts +14 -0
- package/src/Domain/Entities/Coupon.ts +44 -0
- package/src/Domain/Entities/Marketing.ts +26 -0
- package/src/Domain/PromotionRules/BuyXGetYRule.ts +8 -0
- package/src/Domain/PromotionRules/FixedAmountDiscountRule.ts +8 -0
- package/src/Domain/PromotionRules/FreeShippingRule.ts +8 -0
- package/src/Domain/PromotionRules/PercentageDiscountRule.ts +8 -0
- package/src/Infrastructure/Persistence/AtlasMarketingRepository.ts +24 -0
- package/src/Infrastructure/Persistence/Migrations/20250101_create_marketing_tables.ts +47 -0
- package/src/Interface/Http/Controllers/AdminMarketingController.ts +24 -0
- package/src/index.ts +82 -0
- package/src/manifest.json +12 -0
- package/tests/advanced-rules.test.ts +105 -0
- package/tests/grand-review.ts +95 -0
- package/tests/unit.test.ts +120 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { DB, Schema } from '@gravito/atlas'
|
|
2
|
+
import { PlanetCore, setApp } from '@gravito/core'
|
|
3
|
+
import { CommerceServiceProvider } from '../../commerce/src/index'
|
|
4
|
+
import { MarketingServiceProvider } from '../src/index'
|
|
5
|
+
|
|
6
|
+
async function marketingGrandReview() {
|
|
7
|
+
console.log('\n🌟 [Marketing Grand Review] 啟動跨模組行銷聯動校閱...')
|
|
8
|
+
|
|
9
|
+
// 直接對 Atlas 進行底層配置
|
|
10
|
+
DB.configure({
|
|
11
|
+
default: 'sqlite',
|
|
12
|
+
connections: {
|
|
13
|
+
sqlite: { driver: 'sqlite', database: ':memory:' },
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// 1. 初始化核心
|
|
18
|
+
const core = await PlanetCore.boot({
|
|
19
|
+
config: {
|
|
20
|
+
'database.default': 'sqlite',
|
|
21
|
+
'database.connections.sqlite': { driver: 'sqlite', database: ':memory:' },
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
setApp(core)
|
|
25
|
+
|
|
26
|
+
console.log('📦 [Database] 準備資料表...')
|
|
27
|
+
// 使用 Atlas 靜態物件執行
|
|
28
|
+
await Schema.create('product_variants', (table) => {
|
|
29
|
+
table.string('id').primary()
|
|
30
|
+
table.string('sku')
|
|
31
|
+
table.string('name')
|
|
32
|
+
table.decimal('price', 15, 2)
|
|
33
|
+
table.integer('stock')
|
|
34
|
+
table.integer('version').default(1)
|
|
35
|
+
table.timestamp('updated_at').nullable()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// 執行遷移
|
|
39
|
+
const commMigration = await import(
|
|
40
|
+
'../../commerce/src/Infrastructure/Persistence/Migrations/20250101_create_commerce_tables'
|
|
41
|
+
)
|
|
42
|
+
await commMigration.default.up()
|
|
43
|
+
|
|
44
|
+
const mktMigration = await import(
|
|
45
|
+
'../src/Infrastructure/Persistence/Migrations/20250101_create_marketing_tables'
|
|
46
|
+
)
|
|
47
|
+
await mktMigration.default.up()
|
|
48
|
+
|
|
49
|
+
// 2. 註冊插件
|
|
50
|
+
await core.use(new CommerceServiceProvider())
|
|
51
|
+
await core.use(new MarketingServiceProvider())
|
|
52
|
+
await core.bootstrap()
|
|
53
|
+
|
|
54
|
+
// 3. 設定促銷規則
|
|
55
|
+
await DB.table('promotions').insert({
|
|
56
|
+
id: 'promo_1',
|
|
57
|
+
name: 'Grand Opening Sale',
|
|
58
|
+
type: 'cart_threshold',
|
|
59
|
+
configuration: JSON.stringify({ min_amount: 2000, discount: 200 }),
|
|
60
|
+
priority: 100,
|
|
61
|
+
is_active: true,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// 4. 下單驗證
|
|
65
|
+
await DB.table('product_variants').insert({
|
|
66
|
+
id: 'v1',
|
|
67
|
+
sku: 'IPHONE',
|
|
68
|
+
name: 'iPhone 16 Pro',
|
|
69
|
+
price: 30000,
|
|
70
|
+
stock: 10,
|
|
71
|
+
version: 1,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const placeOrder = core.container.make<any>('commerce.place-order')
|
|
75
|
+
console.log('\n🧪 [Test] 執行下單...')
|
|
76
|
+
const result = await placeOrder.execute({
|
|
77
|
+
memberId: 'user_1',
|
|
78
|
+
items: [{ variantId: 'v1', quantity: 1 }],
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const order = (await DB.table('orders').where('id', result.orderId).first()) as any
|
|
82
|
+
console.log(`✅ 訂單驗證完成: ${order.total_amount}`)
|
|
83
|
+
|
|
84
|
+
if (Number(order.total_amount) !== 29860) {
|
|
85
|
+
throw new Error('Calculation mismatch!')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log('\n🎉 [Marketing Grand Review] 跨模組驗證圓滿成功!')
|
|
89
|
+
process.exit(0)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
marketingGrandReview().catch((err) => {
|
|
93
|
+
console.error('💥 校閱失敗:', err)
|
|
94
|
+
process.exit(1)
|
|
95
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
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 { CouponService } from '../src/Application/Services/CouponService'
|
|
5
|
+
import type { PromotionEngine } from '../src/Application/Services/PromotionEngine'
|
|
6
|
+
import { MarketingServiceProvider } from '../src/index'
|
|
7
|
+
|
|
8
|
+
describe('Marketing Satellite - Coupon & Promotion', () => {
|
|
9
|
+
let core: PlanetCore
|
|
10
|
+
let promoEngine: PromotionEngine
|
|
11
|
+
let couponService: CouponService
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
core = await PlanetCore.boot({
|
|
15
|
+
config: {
|
|
16
|
+
'database.default': 'sqlite',
|
|
17
|
+
'database.connections.sqlite': { driver: 'sqlite', database: ':memory:' },
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
DB.addConnection('default', { driver: 'sqlite', database: ':memory:' })
|
|
21
|
+
|
|
22
|
+
await Schema.dropIfExists('promotions')
|
|
23
|
+
await Schema.create('promotions', (table) => {
|
|
24
|
+
table.string('id').primary()
|
|
25
|
+
table.string('name')
|
|
26
|
+
table.string('type')
|
|
27
|
+
table.text('configuration')
|
|
28
|
+
table.integer('priority').default(0)
|
|
29
|
+
table.boolean('is_active').default(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
await Schema.dropIfExists('coupons')
|
|
33
|
+
await Schema.create('coupons', (table) => {
|
|
34
|
+
table.string('id').primary()
|
|
35
|
+
table.string('code').unique()
|
|
36
|
+
table.string('name')
|
|
37
|
+
table.string('type')
|
|
38
|
+
table.decimal('value', 15, 2)
|
|
39
|
+
table.text('configuration').nullable()
|
|
40
|
+
table.integer('usage_limit').nullable()
|
|
41
|
+
table.integer('usage_count').default(0)
|
|
42
|
+
table.boolean('is_active').default(true)
|
|
43
|
+
table.timestamp('expires_at').nullable()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
await core.use(new MarketingServiceProvider())
|
|
47
|
+
await core.bootstrap()
|
|
48
|
+
|
|
49
|
+
promoEngine = core.container.make('marketing.promotion-engine')
|
|
50
|
+
couponService = core.container.make('marketing.coupon-service')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('應該能正確套用固定金額折價券', async () => {
|
|
54
|
+
await DB.table('coupons').insert({
|
|
55
|
+
id: 'c1',
|
|
56
|
+
code: 'SAVE100',
|
|
57
|
+
name: 'Welcome Pack',
|
|
58
|
+
type: 'fixed',
|
|
59
|
+
value: 100,
|
|
60
|
+
is_active: true,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const order = { subtotalAmount: 1000 }
|
|
64
|
+
const adj = await couponService.getAdjustment('SAVE100', order)
|
|
65
|
+
|
|
66
|
+
expect(adj?.amount).toBe(-100)
|
|
67
|
+
expect(adj?.label).toContain('Welcome Pack')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('應該能正確套用百分比折價券', async () => {
|
|
71
|
+
await DB.table('coupons').insert({
|
|
72
|
+
id: 'c2',
|
|
73
|
+
code: 'OFF10',
|
|
74
|
+
name: '10% Discount',
|
|
75
|
+
type: 'percent',
|
|
76
|
+
value: 10,
|
|
77
|
+
is_active: true,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const order = { subtotalAmount: 2000 }
|
|
81
|
+
const adj = await couponService.getAdjustment('OFF10', order)
|
|
82
|
+
|
|
83
|
+
expect(adj?.amount).toBe(-200) // 2000 * 0.1
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('當折價券過期時應拋出錯誤', async () => {
|
|
87
|
+
await DB.table('coupons').insert({
|
|
88
|
+
id: 'c3',
|
|
89
|
+
code: 'EXPIRED',
|
|
90
|
+
name: 'Old Coupon',
|
|
91
|
+
type: 'fixed',
|
|
92
|
+
value: 100,
|
|
93
|
+
expires_at: '2020-01-01',
|
|
94
|
+
is_active: true,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const order = { subtotalAmount: 1000 }
|
|
98
|
+
expect(couponService.getAdjustment('EXPIRED', order)).rejects.toThrow('expired')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('應該能正確計算買 2 送 1 (Buy X Get Y)', async () => {
|
|
102
|
+
await DB.table('promotions').insert({
|
|
103
|
+
id: 'p3',
|
|
104
|
+
name: 'B2G1 Free',
|
|
105
|
+
type: 'buy_x_get_y',
|
|
106
|
+
configuration: JSON.stringify({ target_sku: 'TEST_SKU', x: 2, y: 1 }),
|
|
107
|
+
is_active: true,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const order = {
|
|
111
|
+
items: [{ props: { sku: 'TEST_SKU', quantity: 2, unitPrice: 500 } }],
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const adjustments = await promoEngine.applyPromotions(order)
|
|
115
|
+
|
|
116
|
+
expect(adjustments).toHaveLength(1)
|
|
117
|
+
expect(adjustments[0].amount).toBe(-500) // 免費送一個 500 元的商品
|
|
118
|
+
expect(adjustments[0].label).toContain('Free')
|
|
119
|
+
})
|
|
120
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
"baseUrl": ".",
|
|
6
|
+
"paths": {
|
|
7
|
+
"@gravito/core": [
|
|
8
|
+
"../../packages/core/src/index.ts"
|
|
9
|
+
],
|
|
10
|
+
"@gravito/*": [
|
|
11
|
+
"../../packages/*/src/index.ts"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"types": [
|
|
15
|
+
"bun-types"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"include": [
|
|
19
|
+
"src/**/*"
|
|
20
|
+
],
|
|
21
|
+
"exclude": [
|
|
22
|
+
"node_modules",
|
|
23
|
+
"dist",
|
|
24
|
+
"**/*.test.ts"
|
|
25
|
+
]
|
|
26
|
+
}
|