@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,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,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 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 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
|
+
})
|