消費者在購物車放了三件商品,每件 499 元,合計 1,497 元。三件都符合「滿 1,000 元回饋 10%」的活動,購物車顯示預估回饋 149 點。

  • TG:整張交易訂單/訂單群組。本例的總金額是 1,497 元。
  • TS:TG 底下的訂單明細/訂單子單。本例假設有兩張,金額分別是 998 元與 499 元。
  • Product.GroupKey:告訴 Engine 哪些商品屬於同一計算組。若以 TS 給點,A、B 應使用相同的 TS 識別值,C 則使用另一個值。
三件商品分屬兩張 TS;A、B 的 key 相同,所以 Independent 會先把它們合為一組。

Independent 是同一張 TS 的商品先合併並以此為單位計算(不是每個 SKU 各算一次)。因此,若 Engine 收得到這兩張 TS 的 GroupKey,計算會是 floor(998 × 10%) + floor(499 × 10%) = 99 + 49 = 148 點。Merged 則把整張 TG 合起來算:floor(1,497 × 10%) = 149 點。

兩種模式都先用符合活動條件的總金額判斷門檻、選出回饋比例。

差別在於後續計算和無條件捨去的單位,Merged 以 TG 算一次,而 Independent 以 TS 分別計算、分別捨去。因此 TS 越多、各 TS 算出的小數越多,Independent 最終的點數通常越少,且通常不會高於 Merged

那為什麼開頭的購物車仍顯示與 Merged 計算結果相同的 149 點?因為這條預估鏈路送進 Engine 的 GroupKey 都是 null。規則雖然選了 Independent,Engine 在這裡仍只分得出一組而無法依據 TS 各自計算

剛才那筆購物車,整張 TG 合起來算是 149 點;假設依兩張 TS 分開算,則是 148 點。只差一點,問題卻不在算術,而在活動規則究竟把誰當成「一組」

決定計算方式的是 PointCalculateType。本文討論的主要是這兩種比例回饋活動

  • RewardReachPriceWithRatePoint
  • RewardReachPriceWithRatePoint2

程式裡只有兩個選項

1
2
3
4
5
6
7
8
public enum RewardPointCalculateType
{
// TG
Merged = 1,

// TS
Independent = 2
}

Merged 是整張 TG 合起來算;Independent 是依 Product.GroupKey 分組算

其中,RewardReachPriceWithRatePoint2 預設選的是 Merged:

1
2
public RewardPointCalculateType PointCalculateType { get; set; }
= RewardPointCalculateType.Merged;
規則選 Independent 只決定走分組分支;分出幾組仍取決於實際傳入的 GroupKey。

前面假設商品 A、B 屬於第一張 TS,金額 998 元;商品 C 屬於第二張 TS,金額 499 元。活動規則是「滿 1,000 元回饋 10%」

998 元離門檻只差 2 元,難道第一張 TS 要在門口被攔下來?先別急。Engine 判斷門檻時,看的不是各張 TS,而是所有符合活動商品的總額:998 + 499 = 1,497 元。因此這筆交易達到門檻,選用 10% 的回饋比例

程式的順序也是如此

1
2
3
4
5
6
7
8
9
10
11
var items = this.GetMatchedProductScopeItems(context)
.OrderBy(x => x.SalePrice)
.ThenByDescending(x => x.Id)
.ToArray();

var totalPrice = items.Sum(x => x.SalePrice);

var priceRateAndPointPair = threshold.ReachPriceRatePointPairs
.Where(x => x.ReachPrice <= totalPrice)
.OrderByDescending(x => x.ReachPrice)
.FirstOrDefault();

GetMatchedProductScopeItems 先找出符合活動範圍的商品,Sum 算出總額,再由 ReachPriceRatePointPairs 選出這個總額適用的門檻與比例。這幾步不會因為選了 Independent,就改成每張 TS 各闖一次門檻。

到這裡,門檻和 10% 都已經決定;接下來才輪到 PointCalculateType 決定:要把 1,497 元合起來算點數,還是依 GroupKey 分組後各算各的。

998 元的 TS 不必單獨跨過 1,000 元;門檻先看 1,497 元總額。

門檻已經過了,比例也選好了 10%。Merged 接下來做的事很乾脆:先把符合活動的商品金額全部合起來,算完點數才捨去小數

程式只有這一行:

1
var points = (totalPrice * priceRateAndPointPair.Rate).Fix(0);

放回開頭的例子,第一張 TS 是 998 元,第二張 TS 是 499 元。Merged 不在這裡分兩次算,而是先合成 1,497 元,乘上 10% 得 149.7 點,最後取整為 149 點。

Fix(0) 的核心實作如下。對這筆正數金額,它會把小數向下取整:

1
var output = Math.Floor(input * power) / power;

所以 Merged 的關鍵是整張 TG 算一次、捨一次;這 149 點寫成一筆 PromotionRecord,其 Group 標記為 Merged。

兩張 TS 的小數先合在一起,整張 TG 最後只執行一次 Fix(0)。

Independent 換了一種排隊方式:先看 Product.GroupKey,同一個 key 的商品站在同一組,再讓每組各算各的點數。假設開頭的三件商品有正確的 TS 識別值,送進 Engine 時會像這樣:

商品 金額 Product.GroupKey(示意)
A 499 元 TS1
B 499 元 TS1
C 499 元 TS2

A、B 雖是兩件商品,卻同屬 TS1,所以要先合成 998 元;C 屬於 TS2,是另一組。程式正是照這個順序處理:

1
2
3
4
decimal points = items
.GroupBy(g => g.Product.GroupKey)
.Sum(x =>
x.Sum(g => g.SalePrice * priceRateAndPointPair.Rate).Fix(0));

GroupBy 先把商品分組;內層的 Sum 算出每組套用 10% 後的點數,接著每組各做一次 Fix(0);最外層的 Sum 才把 99 點與 49 點加成 148 點。

同樣是 1,497 元、同樣選 10%,Merged 得到 149 點,這裡得到 148 點。差的 1 點來自每張 TS 都先捨去自己的小數,不是重新判斷門檻,也不是每個 SKU 各算一次。

但 GroupBy 只認 GroupKey,不會猜商品應該屬於哪張 TS。若三件商品的 key 全是 null,它們就會排成同一組。這正是目前購物車預估鏈路的情況:規則雖選了 Independent,結果仍是 149 點;PromotionRecord.Group 標記為 Independent,也不代表資料真的被分成多張 TS。

另外,若各組加總後的點數小於 0,程式會將結果調整為 0:

1
2
3
4
if (points < decimal.Zero)
{
points = 0;
}

一般化來看,Merged 先合併各 TS 金額再取整;Independent 先讓各 TS 分別取整,再把點數加總。

在各組金額非負、沒有其他點數限制的情況下,Independent 的結果小於或等於 Merged;有時會少點,也可能剛好相同。

分組後各自捨去小數,99 + 49 才是最後的點數。

看到 Independent 得到 148 點,先別替它貼上「一定比較少」的標籤。換一筆符合活動的交易:兩張 TS 分別是 400 元與 600 元,合計 1,000 元,回饋比例同樣是 10%。

兩張 TS 各自算出的點數本來就是整數,沒有小數可捨。分開計算會多幾次捨去的機會,卻不保證每次都少點。

兩張 TS 各得到整數點數,因此即使捨去次數不同,結果同為 100 點。

再把開頭的三件 499 元商品假設分到三張不同的 TS,每張各有自己的 GroupKey。商品和比例都沒變,變的是分組方式。

兩點差在哪裡?合起來算,149.7 只捨去最後的 0.7;分三張 TS 算,每張的 49.9 都先捨去 0.9,三次共捨去 2.7。兩種捨法相差整整 2 點。

這是「有三個不同 GroupKey」的示範;目前購物車鏈路的三件商品都是 GroupKey=null,不會在那裡自動變成三張 TS。

差距來自三次各自取整,不是因為活動門檻重新判斷。

前面兩個例子看起來只差一點,剛好可以確認計算單位:Independent 看的是 TS,不是商品件數,也不是 SKU 數量。

回到開頭的分法:A、B 各 499 元,同屬 TS1;C 也是 499 元,屬於 TS2。正確算法是先合併同張 TS 的商品,因此 TS1 得到 99 點,TS2 得到 49 點,合計 148 點。

若把 A、B、C 誤當成各自一組,會算出 49 + 49 + 49 = 147 點。那是三張 TS 的答案,不是這筆兩張 TS 的答案。Engine 用 Product.GroupKey 分組,正是為了分清這兩種情況。

ProductItem 的件數固定為三;GroupKey 決定的是它們被合成兩組還是三組。

如果活動有兩級門檻:「滿 500 元回饋 10%」、「滿 800 元回饋 20%」,三張 TS 分別是 100、400、300 元,會發生什麼事?

光看 TS1,100 元連第一級門檻都碰不到;但 Engine 先用 800 元總額選出 20%,才進入 TG 或 TS 的點數計算。因此 TS1 也會套用 20%。

這次兩種模式碰巧同為 160 點。要記住的是計算順序:先用符合活動的總額選比例,再決定點數要合併算,還是按 GroupKey 分組算。

即使 TS1 自己只有 100 元,它仍套用總額 800 元選出的 20%。

再給活動加上一條規則:最多回饋 MaximumPoints = 100 點。假設三件 499 元商品各屬於不同 TS,Merged 原本算出 149 點,Independent 原本算出 147 點;但最後兩邊都只能給 100 點。

程式會在依模式算完點數後,呼叫 RestrictPoints:

1
Point = RestrictPoints(points);

Independent 是各 TS 計算、捨去、加總之後才套上限。最終同樣顯示 100 點,只代表兩個結果都碰到了上限,不能倒推兩種模式原本算得一樣。

兩種模式原始點數不同,但都在加總後被同一個 100 點上限截住。

最後看一筆專門測試負值處理的資料。假設規則已選用 1% 回饋比例,三組金額分別是 1,580、480、-2,000 元。

這裡的 Fix(0) 使用 Math.Floor;對負數也會向下取整。Independent 分支加總後若小於 0,會再將點數調整為 0:

1
2
3
4
if (points < decimal.Zero)
{
points = 0;
}

所以這筆測試資料的最後結果是 0 點。這道保護發生在各組點數加總之後;一般正數商品的 149/148/147 點差異,仍是前面所說的分組與捨去造成的。

先算出各組點數並加總,負值保護才把 -1 調整成 0。

前面的 148 點、147 點,都有一個前提:商品帶著可區分 TS 的 GroupKey。回到消費者真正看到的購物車,問題就來了:活動已設定 Independent,為什麼預估點數還是 149?沿著資料走一趟,比只看規則名稱更容易找到答案。

這裡追的是購物車預估回饋點數。訂單成立後如何實際發點,屬於另一條鏈路,本文尚未追查。

進入購物車,就一定會重算嗎?

GET 取得既有結果;POST calculate 才走到 FrontendAPI 與 Engine。
Shopping API 呼叫 Cart API 這次請求會呼叫 PromotionFrontendAPI 嗎?
GET /api/carts POST /api/carts/get 不會。Cart 讀取快取中已有的購物車結果。
POST /api/carts/calculate POST /api/carts/calculate 會。Cart 的 CalculatePromotionProcessor 進一步呼叫 FrontendAPI 的 POST /api/cart-calculate。

因此「打開購物車」只描述了消費者的動作,還不能說這次請求一定重跑了 Engine。先看 mweb 呼叫哪支 Shopping API:可能只是讀取先前保存的結果,也可能走重新計算。

重新計算時,資料怎麼走?

重新計算的主線很短:

1
2
3
4
mweb → Shopping POST /api/carts/calculate
→ Cart POST /api/carts/calculate
→ FrontendAPI POST /api/cart-calculate
→ PromotionEngine.ProcessPromotion

短的是 API 路線;每一站整理的資料卻不同。Shopping 的 CartService.CalculateAsync 經 CalculateProcessor 把 UK 交給 Cart;Cart 取出快取資料,經物流與促購 Processor 整理請求,FrontendAPI 攤平數量、載入活動規則,再建立 Engine 使用的商品資料

Shopping 傳識別碼;Cart 自快取補齊商品後才送 FrontendAPI;後者建立 Engine 的逐件商品。
步驟 負責程式 傳遞/轉換的資料
Shopping → Cart Shopping/.../CartProcessor/CalculateProcessor.cs CartCalculateRequest 主要傳 CartUniqueKey,沒有把購物車商品逐件重新裝進這筆 request。
Cart 取出購物車 Cart/.../Processor/Cart/Create/LoadCacheCartDataProcessor.cs、ProcessorDefinitionCenter.CalculateCartProcessorLayers 依 ShopId、MemberId、CartUniqueKey 從快取取出完整 CartEntity,再經 ExecuteLogisticCalculateProcessor、CalculatePromotionProcessor。
Cart 整理促購資料 Cart/.../Promotion/PromotionService.cs 的 ArrangePromotionPayloadAsync、GetPromotionRequestSkuListAsync 從 SalepageGroupList 建立 SalepageSkuList。每列有 SkuId、Qty、Price、CartId、商品選項等資訊,但請求商品型別沒有 GroupKey。
Cart → FrontendAPI Cart/.../Promotion/PromotionHttpClient.cs 的 Calculate 將含商品、商店、會員、金物流等資料的 CalculateRequestEntity 序列化為 JSON,POST 到 /api/cart-calculate。Merged/Independent 不是 Shopping 在這筆請求中指定的。
FrontendAPI → Engine FrontendAPI/.../Calculates/CalculateService.cs、Groups/BaseProcessGroup.cs CreateProcessContextAsync 依 Qty 攤成單件;BottomGroup 載入活動規則,CreateShoppingCartContext 建立各件的 ProductItem、PurchasedItem,再交給 Engine。此處沒有設定 GroupKey。
Engine → Cart → Shopping RewardReachPriceWithRatePoint2.cs、Cart 的 CalculatePromotionProcessor.cs Engine 回傳 PromotionRecord.Point;Cart 寫入 CartEntity.PromotionInfoList[].Points、保存快取,最後把購物車結果回給 Shopping。

實際走一遍:購物車兩列,計算時變三件

為了看清楚 Qty 怎麼轉成 Engine 的商品資料,這裡換一筆總額同為 1,497 元的示意購物車。商品 X 買兩件、商品 Y 買一件,每件 499 元;都符合「滿 1,000 元回饋 10%」活動,沒有其他折扣。以下 ID 與資料只是範例,並非實際 API 紀錄。

Cart 快取中的商品列 CartId SkuId Qty 每件價格
商品 X 101 501 2 499 元
商品 Y 102 502 1 499 元

① Shopping → Cart:只用識別碼要求重算。Shopping 建立 CartCalculateRequest,POST 到 Cart 的 /api/carts/calculate:

1
CartUniqueKey = "cart-demo-123"

此時的 request 沒有上表的商品列。Cart 的 LoadCacheCartDataProcessor 使用識別碼,連同請求環境中的商店與會員資訊,從快取取出含 X、Y 的完整 CartEntity。Shopping 傳來的像是領車的號碼牌,商品明細還在 Cart 快取裡。

② Cart → FrontendAPI:Cart 才開始組完整促購 request。ArrangePromotionPayloadAsync 從 CartEntity.SalepageGroupList 取得商品;GetPromotionRequestSkuListAsync 轉成下列 SalepageSkuList。PromotionHttpClient.Calculate 將包含這份清單的 CalculateRequestEntity 以 JSON POST 到 /api/cart-calculate。

1
2
SalepageSkuList[0]:CartId=101, SkuId=501, Qty=2, Price=499
SalepageSkuList[1]:CartId=102, SkuId=502, Qty=1, Price=499

同一個 request 還有 Shop、User、Channel、Payment、Shipping、CouponList 等資料,這裡先聚焦商品。關鍵是:這份商品清單的型別沒有 GroupKey 欄位。Merged/Independent 也不是由這兩列商品指定,而是 FrontendAPI 載入活動規則後才知道。

③ FrontendAPI → Engine:Qty=2 的 X 被攤成兩件。CreateProcessContextAsync 的 for (i < Qty) 產生三筆 SalepageSkuItemEntity;CreateShoppingCartContext 再為每筆建立一個 ProductItem。兩列購物車商品,到這裡變成三筆計算用商品。

Qty 決定 ProductItem 筆數;Product.GroupKey 才決定 Independent 的組數。
Engine 中的單件商品 Index CartId SkuId SalePrice Product.GroupKey
X 的第 1 件 1 101 501 499 null
X 的第 2 件 2 101 501 499 null
Y 的第 1 件 3 102 502 499 null

X 的兩件雖有相同的 CartId、SkuId,仍各自成為一筆計算用商品;三件的 GroupKey 都是 null。Engine 不會拿 CartId、SkuId 或 Index 代替 GroupKey 分組。

④ Engine → Cart → Shopping:算完再把點數送回去。三件商品的 SalePrice 合計 1,497 元,達到 1,000 元門檻,選用 10%。Merged 算出 floor(1,497 × 10%) = 149 點;即使活動規則是 Independent,目前這條路徑的三個 GroupKey 都是 null,仍只形成一組,也算出 149 點。Engine 將 PromotionRecord.Point 回傳 Cart;Cart 寫入 PromotionInfoList[].Points、保存快取,再把結果回給 Shopping。

一件商品對應一個 ProductItem,不代表一件一張 TS

剛才 X 的 Qty=2,在 CreateProcessContextAsync 的迴圈裡變成兩筆單件商品;Y 的 Qty=1 變成一筆。每筆都有自己的 Index,BaseProcessGroup.CreateShoppingCartContext 再逐件建立 ProductItem 並呼叫 Purchase()。所以 Qty 的每一件,確實各有一個計算用的 ProductItem。

可是 ProductItem 是「一件商品」的資料,不是「一張 TS」的證明。Independent 真正拿來分組的是 Product.GroupKey:相同 key 合併,不同 key 分開。Engine 不會從 SkuId、CartId 或 Index 猜出 TS 單號;在這條購物車路徑,三件商品的 key 都維持 null,最後自然只分得出一組。

同樣三件商品,分組不同就有不同點數

把三件各 499 元、都符合活動的商品排在一起看。假設滿 1,000 元回饋 10%,沒有其他折扣與點數上限;四種算法都先以 1,497 元總額通過門檻並選用 10%。

活動設定與 GroupKey Engine 看見的計算組 點數
Merged 整張 TG 一組 floor(1,497 × 10%) = 149
Independent,三件都是 null(目前購物車) 仍是一組 floor(1,497 × 10%) = 149
Independent,前兩件 TS1、第三件 TS2(假設) 兩組:998、499 元 99 + 49 = 148
Independent,三件各有不同 key(假設) 三組:各 499 元 49 + 49 + 49 = 147

表中最容易看漏的是第二列:PromotionRecord.Group 即使標示 Independent,也只能說程式走了這個分支,不能據此認定它真的分出了多張 TS。是否分組,還得看呼叫方有沒有提供不同的 GroupKey。

模式相同時,GroupKey 的資料形狀仍能讓結果從 149 變成 148 或 147。

若商品經過前面的折扣計算,Engine 使用的是當時符合活動商品的 SalePrice,不一定等於原始售價;上表刻意排除折扣,讓分組差異看得清楚。

basket-calculate 也會填 GroupKey 嗎?

POST /api/basket-calculate 依 PromotionSourceType 選擇 BasketGroup,並共用 CreateProcessContextAsync。一般 PromotionBasketGroup 使用 BaseProcessGroup.CreateShoppingCartContext;CustomRuleBasketGroup 也先呼叫這個 base 方法,另外設定計算日期。因此,若 basket 計算載入 Independent 比例回饋規則,這條商品建立路徑目前也沒有填入 TS GroupKey。

FrontendAPI 確實還有其他流程會填 GroupKey,但填的是各自業務需要的分組值:換價流程使用組合商品組號或 CartExtendInfoItemGroup;點數折抵服務則結合 SKU 與選項資訊,計算折抵上限。這些流程各自建立 Engine 商品資料,不會自動替回饋活動的 cart-calculate 或 basket-calculate 補上 TS 識別值。

其他流程有 GroupKey,不能推論 cart-calculate 或 basket-calculate 也會帶 TS 識別值。