@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
package/.dockerignore
ADDED
package/.env.example
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Application
|
|
2
|
+
APP_NAME="marketing"
|
|
3
|
+
APP_ENV=development
|
|
4
|
+
APP_KEY=
|
|
5
|
+
APP_DEBUG=true
|
|
6
|
+
APP_URL=http://localhost:3000
|
|
7
|
+
|
|
8
|
+
# Server
|
|
9
|
+
PORT=3000
|
|
10
|
+
|
|
11
|
+
# Database
|
|
12
|
+
DB_CONNECTION=sqlite
|
|
13
|
+
DB_DATABASE=database/database.sqlite
|
|
14
|
+
|
|
15
|
+
# Cache
|
|
16
|
+
CACHE_DRIVER=memory
|
|
17
|
+
|
|
18
|
+
# Logging
|
|
19
|
+
LOG_LEVEL=debug
|
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Marketing Satellite 架構說明 🎯
|
|
2
|
+
|
|
3
|
+
## 1. 規則策略模式 (Strategy Pattern)
|
|
4
|
+
我們將每一種行銷玩法抽象為 `IPromotionRule`。
|
|
5
|
+
當 `PromotionEngine` 運行時,它會根據資料庫中活動的 `type` 動態加載對應的策略類別執行 `match(order, config)`。這確保了系統可以在不改動引擎代碼的情況下,透過增加檔案來支援無限的新玩法。
|
|
6
|
+
|
|
7
|
+
## 2. 三段推進實作 (Marketing Stages)
|
|
8
|
+
|
|
9
|
+
### Stage 1: Standard
|
|
10
|
+
- 直接查詢 SQL 中的 `promotions` 與 `coupons` 表。
|
|
11
|
+
- 適用於日常運作。
|
|
12
|
+
|
|
13
|
+
### Stage 2: Sport (內存加速)
|
|
14
|
+
- 將「進行中」的系統促銷規則(如滿額折抵)加載至內存快取。
|
|
15
|
+
- 下單時無需查詢 DB 即可完成規則匹配。
|
|
16
|
+
|
|
17
|
+
### Stage 3: Turbo (分佈式鎖)
|
|
18
|
+
- 針對限量的折價券,核銷張數由 Redis `DECR` 原子操作控制。
|
|
19
|
+
- 確保在 K8s 叢集環境下,折價券不會被「超領」。
|
|
20
|
+
|
|
21
|
+
## 3. 數據快照與法律一致性
|
|
22
|
+
Marketing 產生的 `Adjustment` 會被 Commerce 永久保存至 `order_adjustments`。這意味著即便活動結束或規則被刪除,歷史訂單的折扣紀錄依然完整且具備法律效力。
|
package/CHANGELOG.md
ADDED
package/Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
FROM oven/bun:1.0 AS base
|
|
2
|
+
WORKDIR /usr/src/app
|
|
3
|
+
|
|
4
|
+
# Install dependencies
|
|
5
|
+
FROM base AS install
|
|
6
|
+
RUN mkdir -p /temp/dev
|
|
7
|
+
COPY package.json bun.lockb /temp/dev/
|
|
8
|
+
RUN cd /temp/dev && bun install --frozen-lockfile
|
|
9
|
+
|
|
10
|
+
# Build application
|
|
11
|
+
FROM base AS build
|
|
12
|
+
COPY --from=install /temp/dev/node_modules node_modules
|
|
13
|
+
COPY . .
|
|
14
|
+
ENV NODE_ENV=production
|
|
15
|
+
RUN bun run build
|
|
16
|
+
|
|
17
|
+
# Final production image
|
|
18
|
+
FROM base AS release
|
|
19
|
+
COPY --from=build /usr/src/app/dist/bootstrap.js index.js
|
|
20
|
+
COPY --from=build /usr/src/app/package.json .
|
|
21
|
+
|
|
22
|
+
# Create a non-root user for security
|
|
23
|
+
USER bun
|
|
24
|
+
EXPOSE 3000/tcp
|
|
25
|
+
ENTRYPOINT [ "bun", "run", "index.js" ]
|
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @gravito/satellite-marketing 🎯
|
|
2
|
+
|
|
3
|
+
這是 Gravito Galaxy 的行銷與促銷規則引擎。它設計用於在不侵入核心交易邏輯的前提下,動態注入折扣、折價券與贈品。
|
|
4
|
+
|
|
5
|
+
## 🌟 核心特性
|
|
6
|
+
|
|
7
|
+
- **策略驅動引擎**: 基於規則策略模式,輕鬆擴充新的促銷玩法(如買幾送幾)。
|
|
8
|
+
- **折價券生命週期**: 支援 Code 核銷、張數限制、有效期與最低消費限制。
|
|
9
|
+
- **非侵入式聯動**: 透過 Hook 注入 `Adjustment`,完全解耦 Commerce 與 Marketing。
|
|
10
|
+
- **高併發預留**: 支持 Sport 模式內存快取規則,與 Turbo 模式 Redis 原子核銷。
|
|
11
|
+
|
|
12
|
+
## 🚀 促銷玩法 (Built-in Rules)
|
|
13
|
+
|
|
14
|
+
| 類型 | 標籤 | 描述 |
|
|
15
|
+
| :--- | :--- | :--- |
|
|
16
|
+
| `cart_threshold` | 滿額折抵 | 當購物車小計超過門檻,扣除固定金額。 |
|
|
17
|
+
| `buy_x_get_y` | 買 X 送 Y | 針對特定 SKU,每買 X 個就折抵 Y 個的金額。 |
|
|
18
|
+
| `coupon` | 折價券 | 使用者輸入代碼,套用固定或百分比折扣。 |
|
|
19
|
+
|
|
20
|
+
## 🔗 Galaxy 聯動
|
|
21
|
+
|
|
22
|
+
本插件主要監聽 Commerce 的價格計算過濾器:
|
|
23
|
+
- **Filter**: `commerce:order:adjustments`
|
|
24
|
+
- **Action**: `commerce:order-placed` (用於核銷折價券次數)
|
package/WHITEPAPER.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Whitepaper: Gravito Marketing Satellite (Algorithmic Promotion Engine)
|
|
2
|
+
**Version:** 1.0.0 | **Author:** Gravito Engineering Team
|
|
3
|
+
|
|
4
|
+
## 1. Abstract
|
|
5
|
+
The Gravito Marketing Satellite is a decoupled logic injector designed to handle complex commercial incentives. By utilizing a "Passive Observer" pattern, it enables highly dynamic marketing campaigns (Coupons, B2G1, Tiered Pricing) to influence transactions within the Commerce engine without creating hard-coded dependencies.
|
|
6
|
+
|
|
7
|
+
## 2. Architectural Philosophy: The Decoupled Injector
|
|
8
|
+
Traditional e-commerce systems often suffer from "Spaghetti Promotion Logic," where discount checks are scattered across the checkout code. Gravito solves this via **Polymorphic Adjustments**:
|
|
9
|
+
- The core Commerce engine remains "blind" to marketing rules.
|
|
10
|
+
- Marketing logic is encapsulated in **Rules Strategies**.
|
|
11
|
+
- Communication occurs via the `commerce:order:adjustments` filter, where the Marketing satellite injects negative-value adjustments into the order aggregate.
|
|
12
|
+
|
|
13
|
+
## 3. High-Performance Rule Matching
|
|
14
|
+
To support massive scale, the engine implements a tiered matching strategy:
|
|
15
|
+
- **Index-Based Filtering**: Active promotions are pre-filtered by date and status at the database level.
|
|
16
|
+
- **Complexity O(N)**: Rule matching scales linearly with the number of active campaigns, not the number of items in the catalog.
|
|
17
|
+
- **Memory Priming**: In "Sport Mode," rule metadata is hydrated into heap memory, enabling instantaneous matching for system-wide promotions.
|
|
18
|
+
|
|
19
|
+
## 4. Distributed Coupon Governance (Turbo Guard)
|
|
20
|
+
In cluster deployments (AWS ECS / Kubernetes), coupon "Double-Spending" is a critical risk.
|
|
21
|
+
- **Standard Protocol**: SQL-based atomic increments for usage tracking.
|
|
22
|
+
- **Turbo Protocol**: Redis-backed semaphores. When a high-value coupon is entered, the system performs a sub-ms "check-and-decrement" in Redis before committing the transaction to SQL.
|
|
23
|
+
|
|
24
|
+
## 5. Integration Scenarios
|
|
25
|
+
- **Buy X Get Y (B2G1)**: Algorithmic analysis of the cart items to find the cheapest item for discount.
|
|
26
|
+
- **Loyalty Synergy**: Bridges with the Membership satellite to offer exclusive discounts based on the user's `membership_level`.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
*Gravito Framework: Powering Commerce through Precision Engineering.*
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ServiceProvider, Container } from '@gravito/core';
|
|
2
|
+
|
|
3
|
+
declare class MarketingServiceProvider extends ServiceProvider {
|
|
4
|
+
register(container: Container): void;
|
|
5
|
+
getMigrationsPath(): string;
|
|
6
|
+
boot(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export { MarketingServiceProvider };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { ServiceProvider } from "@gravito/core";
|
|
5
|
+
|
|
6
|
+
// src/Application/Services/CouponService.ts
|
|
7
|
+
import { DB } from "@gravito/atlas";
|
|
8
|
+
var CouponService = class {
|
|
9
|
+
constructor(core) {
|
|
10
|
+
this.core = core;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* 根據代碼查找折價券
|
|
14
|
+
*/
|
|
15
|
+
async findByCode(code) {
|
|
16
|
+
this.core.logger.info(`[CouponService] Looking up coupon: ${code}`);
|
|
17
|
+
return DB.table("coupons").where("code", code).first();
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 計算折價券調整金額
|
|
21
|
+
*/
|
|
22
|
+
async getAdjustment(code, _order) {
|
|
23
|
+
this.core.logger.info(`[CouponService] Calculating adjustment for: ${code}`);
|
|
24
|
+
const coupon = await this.findByCode(code);
|
|
25
|
+
if (!coupon) {
|
|
26
|
+
throw new Error("coupon_not_found");
|
|
27
|
+
}
|
|
28
|
+
if (coupon.is_active === false) {
|
|
29
|
+
throw new Error("inactive");
|
|
30
|
+
}
|
|
31
|
+
if (coupon.expires_at) {
|
|
32
|
+
const expiresAt = new Date(coupon.expires_at);
|
|
33
|
+
if (!Number.isNaN(expiresAt.getTime()) && expiresAt < /* @__PURE__ */ new Date()) {
|
|
34
|
+
throw new Error("expired");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const value = Number(coupon.value);
|
|
38
|
+
const subtotal = Number(_order?.subtotalAmount ?? 0);
|
|
39
|
+
let amount = 0;
|
|
40
|
+
const type = String(coupon.type || "").toLowerCase();
|
|
41
|
+
if (type === "fixed") {
|
|
42
|
+
amount = -value;
|
|
43
|
+
} else if (type === "percent" || type === "percentage") {
|
|
44
|
+
amount = -(subtotal * (value / 100));
|
|
45
|
+
} else {
|
|
46
|
+
throw new Error("unsupported_coupon_type");
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
label: `Coupon: ${coupon.name}`,
|
|
50
|
+
amount,
|
|
51
|
+
sourceType: "coupon",
|
|
52
|
+
sourceId: coupon.id ?? coupon.code
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// src/Application/Services/PromotionEngine.ts
|
|
58
|
+
import { DB as DB3 } from "@gravito/atlas";
|
|
59
|
+
|
|
60
|
+
// src/Application/Rules/BuyXGetYRule.ts
|
|
61
|
+
var BuyXGetYRule = class {
|
|
62
|
+
/**
|
|
63
|
+
* config: { target_sku: 'IPHONE', x: 2, y: 1 }
|
|
64
|
+
* 意即:買 2 個 IPHONE 送 1 個 (折抵 1 個的價格)
|
|
65
|
+
*/
|
|
66
|
+
match(order, config) {
|
|
67
|
+
if (!order.items || !Array.isArray(order.items)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const targetItems = order.items.filter((item) => item.props.sku === config.target_sku);
|
|
71
|
+
if (targetItems.length === 0) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const totalQty = targetItems.reduce((sum, item) => sum + item.props.quantity, 0);
|
|
75
|
+
const unitPrice = targetItems[0].props.unitPrice;
|
|
76
|
+
const sets = Math.floor(totalQty / config.x);
|
|
77
|
+
const freeQuantity = sets * config.y;
|
|
78
|
+
if (freeQuantity > 0) {
|
|
79
|
+
return {
|
|
80
|
+
label: `Promotion: Buy ${config.x} Get ${config.y} Free (${config.target_sku})`,
|
|
81
|
+
amount: -(unitPrice * freeQuantity),
|
|
82
|
+
sourceType: "promotion",
|
|
83
|
+
sourceId: "buy_x_get_y"
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// src/Application/Rules/CartThresholdRule.ts
|
|
91
|
+
var CartThresholdRule = class {
|
|
92
|
+
/**
|
|
93
|
+
* config: { min_amount: 2000, discount: 200 }
|
|
94
|
+
*/
|
|
95
|
+
match(order, config) {
|
|
96
|
+
const subtotal = Number(order.subtotalAmount);
|
|
97
|
+
if (subtotal >= config.min_amount) {
|
|
98
|
+
return {
|
|
99
|
+
label: `Promotion: Spend ${config.min_amount} Get ${config.discount} Off`,
|
|
100
|
+
amount: -config.discount,
|
|
101
|
+
// 負數代表折扣
|
|
102
|
+
sourceType: "promotion",
|
|
103
|
+
sourceId: "cart_threshold"
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// src/Application/Rules/CategoryDiscountRule.ts
|
|
111
|
+
var CategoryDiscountRule = class {
|
|
112
|
+
/**
|
|
113
|
+
* config: { category_id: 'electronics', discount_percent: 20 }
|
|
114
|
+
*/
|
|
115
|
+
match(order, config) {
|
|
116
|
+
if (!order.items || !Array.isArray(order.items)) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const eligibleItems = order.items.filter((item) => {
|
|
120
|
+
const path = item.props.options?.categoryPath || "";
|
|
121
|
+
return path.includes(`/${config.category_id}/`);
|
|
122
|
+
});
|
|
123
|
+
if (eligibleItems.length === 0) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const eligibleTotal = eligibleItems.reduce(
|
|
127
|
+
(sum, item) => sum + item.props.totalPrice,
|
|
128
|
+
0
|
|
129
|
+
);
|
|
130
|
+
const discount = eligibleTotal * (config.discount_percent / 100);
|
|
131
|
+
if (discount > 0) {
|
|
132
|
+
return {
|
|
133
|
+
label: `Category Sale: ${config.category_id.toUpperCase()} Items ${config.discount_percent}% Off`,
|
|
134
|
+
amount: -discount,
|
|
135
|
+
sourceType: "promotion",
|
|
136
|
+
sourceId: "category_discount"
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// src/Application/Rules/FreeShippingRule.ts
|
|
144
|
+
var FreeShippingRule = class {
|
|
145
|
+
/**
|
|
146
|
+
* config: { min_amount: 1000 }
|
|
147
|
+
* 意即:滿 1000 元即享免運
|
|
148
|
+
*/
|
|
149
|
+
match(order, config) {
|
|
150
|
+
const subtotal = Number(order.subtotalAmount);
|
|
151
|
+
if (subtotal >= config.min_amount) {
|
|
152
|
+
return {
|
|
153
|
+
label: `Free Shipping (Orders over ${config.min_amount})`,
|
|
154
|
+
amount: -60,
|
|
155
|
+
// 抵銷基礎運費
|
|
156
|
+
sourceType: "promotion",
|
|
157
|
+
sourceId: "free_shipping"
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// src/Application/Rules/MembershipLevelRule.ts
|
|
165
|
+
import { DB as DB2 } from "@gravito/atlas";
|
|
166
|
+
var MembershipLevelRule = class {
|
|
167
|
+
/**
|
|
168
|
+
* config: { target_level: 'gold', discount_percent: 10 }
|
|
169
|
+
*/
|
|
170
|
+
async match(order, config) {
|
|
171
|
+
if (!order.memberId) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const member = await DB2.table("members").where("id", order.memberId).first();
|
|
175
|
+
if (member && member.level === config.target_level) {
|
|
176
|
+
const discount = Number(order.subtotalAmount) * (config.discount_percent / 100);
|
|
177
|
+
return {
|
|
178
|
+
label: `VIP Discount: ${config.target_level.toUpperCase()} Member ${config.discount_percent}% Off`,
|
|
179
|
+
amount: -discount,
|
|
180
|
+
sourceType: "promotion",
|
|
181
|
+
sourceId: "membership_level"
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// src/Application/Services/PromotionEngine.ts
|
|
189
|
+
var PromotionEngine = class {
|
|
190
|
+
constructor(core) {
|
|
191
|
+
this.core = core;
|
|
192
|
+
}
|
|
193
|
+
ruleFor(type) {
|
|
194
|
+
switch (type) {
|
|
195
|
+
case "cart_threshold":
|
|
196
|
+
return new CartThresholdRule();
|
|
197
|
+
case "buy_x_get_y":
|
|
198
|
+
return new BuyXGetYRule();
|
|
199
|
+
case "category_discount":
|
|
200
|
+
return new CategoryDiscountRule();
|
|
201
|
+
case "free_shipping":
|
|
202
|
+
return new FreeShippingRule();
|
|
203
|
+
case "membership_level":
|
|
204
|
+
return new MembershipLevelRule();
|
|
205
|
+
default:
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async applyPromotions(order) {
|
|
210
|
+
this.core.logger.info("[PromotionEngine] Calculating promotions...");
|
|
211
|
+
const applied = [];
|
|
212
|
+
const promotions = await DB3.table("promotions").where("is_active", true).orderBy("priority", "desc").get();
|
|
213
|
+
for (const promo of promotions) {
|
|
214
|
+
const rule = this.ruleFor(String(promo.type || "").toLowerCase());
|
|
215
|
+
if (!rule) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const rawConfig = promo.configuration ?? "{}";
|
|
219
|
+
let config = {};
|
|
220
|
+
if (typeof rawConfig === "string") {
|
|
221
|
+
try {
|
|
222
|
+
config = JSON.parse(rawConfig);
|
|
223
|
+
} catch {
|
|
224
|
+
config = {};
|
|
225
|
+
}
|
|
226
|
+
} else if (rawConfig && typeof rawConfig === "object") {
|
|
227
|
+
config = rawConfig;
|
|
228
|
+
}
|
|
229
|
+
const result = await rule.match(order, config);
|
|
230
|
+
if (result) {
|
|
231
|
+
applied.push(result);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return applied;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// src/Application/UseCases/AdminListCoupons.ts
|
|
239
|
+
import { UseCase } from "@gravito/enterprise";
|
|
240
|
+
|
|
241
|
+
// src/Domain/Entities/Coupon.ts
|
|
242
|
+
import { Entity } from "@gravito/enterprise";
|
|
243
|
+
var Coupon = class _Coupon extends Entity {
|
|
244
|
+
_props;
|
|
245
|
+
constructor(props, id) {
|
|
246
|
+
super(id || crypto.randomUUID());
|
|
247
|
+
this._props = props;
|
|
248
|
+
}
|
|
249
|
+
static create(props, id) {
|
|
250
|
+
return new _Coupon(
|
|
251
|
+
{
|
|
252
|
+
...props,
|
|
253
|
+
usedCount: 0
|
|
254
|
+
},
|
|
255
|
+
id
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
get code() {
|
|
259
|
+
return this._props.code;
|
|
260
|
+
}
|
|
261
|
+
get status() {
|
|
262
|
+
return this._props.status;
|
|
263
|
+
}
|
|
264
|
+
unpack() {
|
|
265
|
+
return { ...this._props };
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// src/Application/UseCases/AdminListCoupons.ts
|
|
270
|
+
var AdminListCoupons = class extends UseCase {
|
|
271
|
+
async execute() {
|
|
272
|
+
return [
|
|
273
|
+
Coupon.create({
|
|
274
|
+
code: "WELCOME2025",
|
|
275
|
+
name: "\u65B0\u5E74\u6B61\u8FCE\u79AE",
|
|
276
|
+
type: "PERCENTAGE",
|
|
277
|
+
value: 10,
|
|
278
|
+
minPurchase: 0,
|
|
279
|
+
startsAt: /* @__PURE__ */ new Date("2025-01-01"),
|
|
280
|
+
expiresAt: /* @__PURE__ */ new Date("2025-12-31"),
|
|
281
|
+
usageLimit: 1e3,
|
|
282
|
+
status: "ACTIVE"
|
|
283
|
+
}),
|
|
284
|
+
Coupon.create({
|
|
285
|
+
code: "SAVE500",
|
|
286
|
+
name: "\u6EFF\u984D\u6298\u62B5",
|
|
287
|
+
type: "FIXED",
|
|
288
|
+
value: 500,
|
|
289
|
+
minPurchase: 5e3,
|
|
290
|
+
startsAt: /* @__PURE__ */ new Date("2025-01-01"),
|
|
291
|
+
expiresAt: /* @__PURE__ */ new Date("2025-06-30"),
|
|
292
|
+
status: "ACTIVE"
|
|
293
|
+
})
|
|
294
|
+
];
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// src/Interface/Http/Controllers/AdminMarketingController.ts
|
|
299
|
+
var AdminMarketingController = class {
|
|
300
|
+
constructor(core) {
|
|
301
|
+
this.core = core;
|
|
302
|
+
}
|
|
303
|
+
async coupons(ctx) {
|
|
304
|
+
try {
|
|
305
|
+
const useCase = this.core.container.make(
|
|
306
|
+
"marketing.usecase.adminListCoupons"
|
|
307
|
+
);
|
|
308
|
+
const coupons = await useCase.execute();
|
|
309
|
+
return ctx.json(
|
|
310
|
+
coupons.map((c) => ({
|
|
311
|
+
id: c.id,
|
|
312
|
+
...c.unpack()
|
|
313
|
+
}))
|
|
314
|
+
);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
return ctx.json({ message: error.message }, 500);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// src/index.ts
|
|
322
|
+
var __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
323
|
+
var MarketingServiceProvider = class extends ServiceProvider {
|
|
324
|
+
register(container) {
|
|
325
|
+
container.singleton("marketing.promotion-engine", () => {
|
|
326
|
+
return new PromotionEngine(this.core);
|
|
327
|
+
});
|
|
328
|
+
container.singleton("marketing.coupon-service", () => {
|
|
329
|
+
return new CouponService(this.core);
|
|
330
|
+
});
|
|
331
|
+
container.bind("marketing.usecase.adminListCoupons", () => new AdminListCoupons());
|
|
332
|
+
container.singleton(
|
|
333
|
+
"marketing.controller.admin",
|
|
334
|
+
() => new AdminMarketingController(this.core)
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
getMigrationsPath() {
|
|
338
|
+
return join(__dirname, "Infrastructure/Persistence/Migrations");
|
|
339
|
+
}
|
|
340
|
+
async boot() {
|
|
341
|
+
const core = this.core;
|
|
342
|
+
if (!core) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const adminCtrl = core.container.make("marketing.controller.admin");
|
|
346
|
+
core.router.prefix("/api/admin/v1/marketing").group((router) => {
|
|
347
|
+
router.get("/coupons", (ctx) => adminCtrl.coupons(ctx));
|
|
348
|
+
});
|
|
349
|
+
const promoEngine = core.container.make("marketing.promotion-engine");
|
|
350
|
+
const couponService = core.container.make("marketing.coupon-service");
|
|
351
|
+
core.hooks.addFilter(
|
|
352
|
+
"commerce:order:adjustments",
|
|
353
|
+
async (adjustments, { order, extras }) => {
|
|
354
|
+
core.logger.info(`\u{1F3AF} [Marketing] \u6B63\u5728\u70BA\u8A02\u55AE ${order.id} \u6383\u63CF\u4FC3\u92B7\u8207\u6298\u50F9\u5238...`);
|
|
355
|
+
const results = [...adjustments];
|
|
356
|
+
const promoAdjustments = await promoEngine.applyPromotions(order);
|
|
357
|
+
results.push(...promoAdjustments);
|
|
358
|
+
if (extras?.couponCode) {
|
|
359
|
+
try {
|
|
360
|
+
const couponAdj = await couponService.getAdjustment(extras.couponCode, order);
|
|
361
|
+
if (couponAdj) {
|
|
362
|
+
results.push(couponAdj);
|
|
363
|
+
}
|
|
364
|
+
} catch (e) {
|
|
365
|
+
core.logger.warn(`\u26A0\uFE0F [Marketing] \u6298\u50F9\u5238\u7121\u6548: ${e.message}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return results;
|
|
369
|
+
}
|
|
370
|
+
);
|
|
371
|
+
core.hooks.addAction("commerce:order-placed", async (payload) => {
|
|
372
|
+
core.logger.info(`\u{1F4DD} [Marketing] \u8A02\u55AE ${payload.orderId} \u5DF2\u5EFA\u7ACB\uFF0C\u6B63\u5728\u8655\u7406\u6298\u50F9\u5238\u6838\u92B7...`);
|
|
373
|
+
});
|
|
374
|
+
core.logger.info("\u{1F6F0}\uFE0F Satellite Marketing is operational");
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
export {
|
|
378
|
+
MarketingServiceProvider
|
|
379
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gravito/satellite-marketing",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsup src/index.ts --format esm --dts --clean --external @gravito/core --external stripe --external @gravito/atlas --external @gravito/enterprise",
|
|
10
|
+
"test": "bun test",
|
|
11
|
+
"typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@gravito/core": "workspace:*",
|
|
15
|
+
"stripe": "^20.1.0",
|
|
16
|
+
"@gravito/atlas": "workspace:*",
|
|
17
|
+
"@gravito/enterprise": "workspace:*"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^25.0.3",
|
|
21
|
+
"tsup": "^8.5.1",
|
|
22
|
+
"typescript": "^5.9.3"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/gravito-framework/gravito.git",
|
|
30
|
+
"directory": "satellites/marketing"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { IPromotionRule, MarketingAdjustment } from '../../Domain/Contracts/IPromotionRule'
|
|
2
|
+
|
|
3
|
+
export class BuyXGetYRule implements IPromotionRule {
|
|
4
|
+
/**
|
|
5
|
+
* config: { target_sku: 'IPHONE', x: 2, y: 1 }
|
|
6
|
+
* 意即:買 2 個 IPHONE 送 1 個 (折抵 1 個的價格)
|
|
7
|
+
*/
|
|
8
|
+
match(order: any, config: any): MarketingAdjustment | null {
|
|
9
|
+
if (!order.items || !Array.isArray(order.items)) {
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// 尋找目標商品
|
|
14
|
+
const targetItems = order.items.filter((item: any) => item.props.sku === config.target_sku)
|
|
15
|
+
|
|
16
|
+
if (targetItems.length === 0) {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 累計總數量與單價快照
|
|
21
|
+
const totalQty = targetItems.reduce((sum: number, item: any) => sum + item.props.quantity, 0)
|
|
22
|
+
const unitPrice = targetItems[0].props.unitPrice
|
|
23
|
+
|
|
24
|
+
// 計算符合幾組 (Buy X Get Y)
|
|
25
|
+
// 這裡我們實作最經典的:買 X 個,其中 Y 個免費
|
|
26
|
+
const sets = Math.floor(totalQty / config.x)
|
|
27
|
+
const freeQuantity = sets * config.y
|
|
28
|
+
|
|
29
|
+
if (freeQuantity > 0) {
|
|
30
|
+
return {
|
|
31
|
+
label: `Promotion: Buy ${config.x} Get ${config.y} Free (${config.target_sku})`,
|
|
32
|
+
amount: -(unitPrice * freeQuantity),
|
|
33
|
+
sourceType: 'promotion',
|
|
34
|
+
sourceId: 'buy_x_get_y',
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { IPromotionRule, MarketingAdjustment } from '../../Domain/Contracts/IPromotionRule'
|
|
2
|
+
|
|
3
|
+
export class CartThresholdRule implements IPromotionRule {
|
|
4
|
+
/**
|
|
5
|
+
* config: { min_amount: 2000, discount: 200 }
|
|
6
|
+
*/
|
|
7
|
+
match(order: any, config: any): MarketingAdjustment | null {
|
|
8
|
+
const subtotal = Number(order.subtotalAmount)
|
|
9
|
+
|
|
10
|
+
if (subtotal >= config.min_amount) {
|
|
11
|
+
return {
|
|
12
|
+
label: `Promotion: Spend ${config.min_amount} Get ${config.discount} Off`,
|
|
13
|
+
amount: -config.discount, // 負數代表折扣
|
|
14
|
+
sourceType: 'promotion',
|
|
15
|
+
sourceId: 'cart_threshold',
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { IPromotionRule, MarketingAdjustment } from '../../Domain/Contracts/IPromotionRule'
|
|
2
|
+
|
|
3
|
+
export class CategoryDiscountRule implements IPromotionRule {
|
|
4
|
+
/**
|
|
5
|
+
* config: { category_id: 'electronics', discount_percent: 20 }
|
|
6
|
+
*/
|
|
7
|
+
match(order: any, config: any): MarketingAdjustment | null {
|
|
8
|
+
if (!order.items || !Array.isArray(order.items)) {
|
|
9
|
+
return null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 遍歷所有品項,尋找屬於該分類 (或其子分類) 的商品
|
|
13
|
+
// 注意:這裡假設 Order Item 中已經快照了商品的 categoryPath (由 Catalog 提供)
|
|
14
|
+
const eligibleItems = order.items.filter((item: any) => {
|
|
15
|
+
const path = item.props.options?.categoryPath || ''
|
|
16
|
+
return path.includes(`/${config.category_id}/`)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
if (eligibleItems.length === 0) {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const eligibleTotal = eligibleItems.reduce(
|
|
24
|
+
(sum: number, item: any) => sum + item.props.totalPrice,
|
|
25
|
+
0
|
|
26
|
+
)
|
|
27
|
+
const discount = eligibleTotal * (config.discount_percent / 100)
|
|
28
|
+
|
|
29
|
+
if (discount > 0) {
|
|
30
|
+
return {
|
|
31
|
+
label: `Category Sale: ${config.category_id.toUpperCase()} Items ${config.discount_percent}% Off`,
|
|
32
|
+
amount: -discount,
|
|
33
|
+
sourceType: 'promotion',
|
|
34
|
+
sourceId: 'category_discount',
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|