TG vs TS 給點
消費者在購物車放了三件商品,每件 499 元,合計 1,497 元。三件都符合「滿 1,000 元回饋 10%」的活動,購物車顯示預估回饋 149 點。
- TG:整張交易訂單/訂單群組。本例的總金額是 1,497 元。
- TS:TG 底下的訂單明細/訂單子單。本例假設有兩張,金額分別是 998 元與 499 元。
- Product.GroupKey:告訴 Engine 哪些商品屬於同一計算組。若以 TS 給點,A、B 應使用相同的 TS 識別值,C 則使用另一個值。
flowchart TB
Cart["購物車:499 元 × 3 件"] --> TG["TG:合計 1,497 元"]
TG --> TS1["TS1:998 元"]
TG --> TS2["TS2:499 元"]
TS1 --> A["A:499 元"]
TS1 --> B["B:499 元"]
TS2 --> C["C:499 元"]
class Cart,TG input
class TS1,TS2 key
class A,B,C neutral
classDef input fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef key fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef neutral fill:#fbf7ef,stroke:#b49b78,color:#493b2d
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。本文討論的主要是這兩種比例回饋活動
RewardReachPriceWithRatePointRewardReachPriceWithRatePoint2
程式裡只有兩個選項
1 | public enum RewardPointCalculateType |
Merged 是整張 TG 合起來算;Independent 是依 Product.GroupKey 分組算
其中,RewardReachPriceWithRatePoint2 預設選的是 Merged:
1 | public RewardPointCalculateType PointCalculateType { get; set; } |
flowchart TB
Rule["載入回饋活動規則"] --> Mode{"PointCalculateType?"}
Mode -->|Merged| TG["整張 TG 一組"]
Mode -->|Independent| Keys{"Product.GroupKey?"}
Keys -->|TS1、TS2| Two["分成兩張 TS"]
Keys -->|全為 null| One["只分得出一組"]
TG --> P149["149 點"]
Two --> P148["148 點"]
One --> P149
class Rule input
class Mode,Keys key
class TG,Two,One neutral
class P149,P148 result
classDef input fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef key fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef neutral fill:#fbf7ef,stroke:#b49b78,color:#493b2d
前面假設商品 A、B 屬於第一張 TS,金額 998 元;商品 C 屬於第二張 TS,金額 499 元。活動規則是「滿 1,000 元回饋 10%」
998 元離門檻只差 2 元,難道第一張 TS 要在門口被攔下來?先別急。Engine 判斷門檻時,看的不是各張 TS,而是所有符合活動商品的總額:998 + 499 = 1,497 元。因此這筆交易達到門檻,選用 10% 的回饋比例
程式的順序也是如此
1 | var items = this.GetMatchedProductScopeItems(context) |
GetMatchedProductScopeItems 先找出符合活動範圍的商品,Sum 算出總額,再由 ReachPriceRatePointPairs 選出這個總額適用的門檻與比例。這幾步不會因為選了 Independent,就改成每張 TS 各闖一次門檻。
到這裡,門檻和 10% 都已經決定;接下來才輪到 PointCalculateType 決定:要把 1,497 元合起來算點數,還是依 GroupKey 分組後各算各的。
flowchart TB
Items["符合活動的商品:998 + 499"] --> Total["總額 1,497 元"]
Total --> Gate{"達 1,000 元門檻?"}
Gate -->|是| Rate["整筆選用 10%"]
Rate --> Mode{"點數計算方式?"}
Mode -->|Merged| All["TG 合算、捨一次"]
Mode -->|Independent| Split["依 GroupKey 分組、各捨一次"]
class Items,Total input
class Gate,Mode key
class Rate,All,Split result
classDef input fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef key fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef neutral fill:#fbf7ef,stroke:#b49b78,color:#493b2d
門檻已經過了,比例也選好了 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。
flowchart LR
A["TS1:998"] --> Sum["TG:1,497"]
B["TS2:499"] --> Sum
Sum --> Raw["× 10% = 149.7"] --> Floor["Fix(0):149 點"]
class A,B input
class Sum,Raw key
class Floor result
classDef input fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef key fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef neutral fill:#fbf7ef,stroke:#b49b78,color:#493b2d
Independent 換了一種排隊方式:先看 Product.GroupKey,同一個 key 的商品站在同一組,再讓每組各算各的點數。假設開頭的三件商品有正確的 TS 識別值,送進 Engine 時會像這樣:
| 商品 | 金額 | Product.GroupKey(示意) |
|---|---|---|
| A | 499 元 | TS1 |
| B | 499 元 | TS1 |
| C | 499 元 | TS2 |
A、B 雖是兩件商品,卻同屬 TS1,所以要先合成 998 元;C 屬於 TS2,是另一組。程式正是照這個順序處理:
1 | decimal points = items |
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 | if (points < decimal.Zero) |
一般化來看,Merged 先合併各 TS 金額再取整;Independent 先讓各 TS 分別取整,再把點數加總。
在各組金額非負、沒有其他點數限制的情況下,Independent 的結果小於或等於 Merged;有時會少點,也可能剛好相同。
flowchart LR
A["A:499|TS1"] --> G1["TS1:998 × 10% = 99.8"]
B["B:499|TS1"] --> G1
C["C:499|TS2"] --> G2["TS2:499 × 10% = 49.9"]
G1 --> F1["Fix(0):99"]
G2 --> F2["Fix(0):49"]
F1 --> Sum["合計 148 點"]
F2 --> Sum
class A,B,C input
class G1,G2 key
class F1,F2,Sum result
classDef input fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef key fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef neutral fill:#fbf7ef,stroke:#b49b78,color:#493b2d
看到 Independent 得到 148 點,先別替它貼上「一定比較少」的標籤。換一筆符合活動的交易:兩張 TS 分別是 400 元與 600 元,合計 1,000 元,回饋比例同樣是 10%。
兩張 TS 各自算出的點數本來就是整數,沒有小數可捨。分開計算會多幾次捨去的機會,卻不保證每次都少點。
flowchart LR
subgraph M["Merged:先合併"]
M1["400 + 600 = 1,000"] --> M2["× 10% = 100"]
end
subgraph I["Independent:先分組"]
I1["400 × 10% = 40"] --> I3["40 + 60 = 100"]
I2["600 × 10% = 60"] --> I3
end
class M1,I1,I2 input
class M2,I3 result
classDef input fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef key fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef neutral fill:#fbf7ef,stroke:#b49b78,color:#493b2d
再把開頭的三件 499 元商品假設分到三張不同的 TS,每張各有自己的 GroupKey。商品和比例都沒變,變的是分組方式。
兩點差在哪裡?合起來算,149.7 只捨去最後的 0.7;分三張 TS 算,每張的 49.9 都先捨去 0.9,三次共捨去 2.7。兩種捨法相差整整 2 點。
這是「有三個不同 GroupKey」的示範;目前購物車鏈路的三件商品都是 GroupKey=null,不會在那裡自動變成三張 TS。
flowchart TB
Base["三件 499 元,各屬不同 TS"] --> M["Merged:149.7 → 149|捨去 0.7"]
Base --> I["Independent:49.9 → 49,重複三次"]
I --> Loss["共捨去 0.9 × 3 = 2.7"]
M --> Gap["結果相差 2 點:149 對 147"]
Loss --> Gap
class Base input
class M,I,Loss key
class Gap result
classDef input fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef key fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef neutral fill:#fbf7ef,stroke:#b49b78,color:#493b2d
前面兩個例子看起來只差一點,剛好可以確認計算單位: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 分組,正是為了分清這兩種情況。
flowchart LR
Start["A、B、C:每件 499"] --> Real["兩張 TS:A+B|C"]
Start --> Other["三張 TS:A|B|C"]
Real --> R["99 + 49 = 148 點"]
Other --> O["49 + 49 + 49 = 147 點"]
class Start input
class Real,Other key
class R,O result
classDef input fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef key fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef neutral fill:#fbf7ef,stroke:#b49b78,color:#493b2d
如果活動有兩級門檻:「滿 500 元回饋 10%」、「滿 800 元回饋 20%」,三張 TS 分別是 100、400、300 元,會發生什麼事?
光看 TS1,100 元連第一級門檻都碰不到;但 Engine 先用 800 元總額選出 20%,才進入 TG 或 TS 的點數計算。因此 TS1 也會套用 20%。
這次兩種模式碰巧同為 160 點。要記住的是計算順序:先用符合活動的總額選比例,再決定點數要合併算,還是按 GroupKey 分組算。
flowchart TB
T["TS1 100 + TS2 400 + TS3 300 = 800 元"] --> Gate{"達哪一級門檻?"}
Gate -->|滿 500| Low["10%"]
Gate -->|滿 800,採最高符合門檻| High["20%"]
High --> Split["三張 TS 都套 20%"]
Split --> Points["20 + 80 + 60 = 160 點"]
class T input
class Gate key
class Low neutral
class High,Split,Points result
classDef input fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef key fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef neutral fill:#fbf7ef,stroke:#b49b78,color:#493b2d
再給活動加上一條規則:最多回饋 MaximumPoints = 100 點。假設三件 499 元商品各屬於不同 TS,Merged 原本算出 149 點,Independent 原本算出 147 點;但最後兩邊都只能給 100 點。
程式會在依模式算完點數後,呼叫 RestrictPoints:
1 | Point = RestrictPoints(points); |
Independent 是各 TS 計算、捨去、加總之後才套上限。最終同樣顯示 100 點,只代表兩個結果都碰到了上限,不能倒推兩種模式原本算得一樣。
最後看一筆專門測試負值處理的資料。假設規則已選用 1% 回饋比例,三組金額分別是 1,580、480、-2,000 元。
這裡的 Fix(0) 使用 Math.Floor;對負數也會向下取整。Independent 分支加總後若小於 0,會再將點數調整為 0:
1 | if (points < decimal.Zero) |
所以這筆測試資料的最後結果是 0 點。這道保護發生在各組點數加總之後;一般正數商品的 149/148/147 點差異,仍是前面所說的分組與捨去造成的。
flowchart LR
A["TS1:15"] --> Sum["15 + 4 - 20 = -1"]
B["TS2:4"] --> Sum
C["TS3:-20"] --> Sum
Sum --> Gate{"結果小於 0?"}
Gate -->|是| Zero["調整為 0 點"]
class A,B,C input
class Sum,Gate key
class Zero result
classDef input fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef key fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef neutral fill:#fbf7ef,stroke:#b49b78,color:#493b2d
前面的 148 點、147 點,都有一個前提:商品帶著可區分 TS 的 GroupKey。回到消費者真正看到的購物車,問題就來了:活動已設定 Independent,為什麼預估點數還是 149?沿著資料走一趟,比只看規則名稱更容易找到答案。
這裡追的是購物車預估回饋點數。訂單成立後如何實際發點,屬於另一條鏈路,本文尚未追查。
進入購物車,就一定會重算嗎?
flowchart LR
Open["消費者進入購物車"] --> Which{"mweb 呼叫哪支 Shopping API?"}
Which -->|GET /api/carts| Get["Cart /api/carts/get:讀快取"]
Which -->|POST /api/carts/calculate| Calc["Cart /api/carts/calculate:重算"]
Calc --> Front["FrontendAPI /api/cart-calculate"]
Front --> Engine["Engine 計算預估回饋"]
class Open input
class Which key
class Get,Calc,Front neutral
class Engine result
classDef input fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef key fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef neutral fill:#fbf7ef,stroke:#b49b78,color:#493b2d
| 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 | mweb → Shopping POST /api/carts/calculate |
短的是 API 路線;每一站整理的資料卻不同。Shopping 的 CartService.CalculateAsync 經 CalculateProcessor 把 UK 交給 Cart;Cart 取出快取資料,經物流與促購 Processor 整理請求,FrontendAPI 攤平數量、載入活動規則,再建立 Engine 使用的商品資料
sequenceDiagram
autonumber
actor Mweb as mweb
participant Shopping
participant Cart
participant Cache as Cart 快取
participant Front as FrontendAPI
participant Engine
Mweb->>Shopping: POST /api/carts/calculate
Shopping->>Cart: POST /api/carts/calculate(CartUniqueKey)
Cart->>Cache: 讀取 CartEntity
Cache-->>Cart: 購物車商品與環境資料
Cart->>Front: POST /api/cart-calculate(SalepageSkuList)
Front->>Front: 攤平 Qty、載入活動規則
Front->>Engine: ProductItem 與規則
Engine-->>Front: PromotionRecord.Point
Front-->>Cart: 預估回饋
Cart->>Cache: 保存 PromotionInfoList.Points
Cart-->>Shopping: 購物車計算結果
Shopping-->>Mweb: 顯示預估點數
| 步驟 | 負責程式 | 傳遞/轉換的資料 |
|---|---|---|
| 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 | SalepageSkuList[0]:CartId=101, SkuId=501, Qty=2, 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。兩列購物車商品,到這裡變成三筆計算用商品。
flowchart LR
X["CartId 101|X|Qty=2"] --> X1["ProductItem X-1|GroupKey=null"]
X --> X2["ProductItem X-2|GroupKey=null"]
Y["CartId 102|Y|Qty=1"] --> Y1["ProductItem Y-1|GroupKey=null"]
X1 --> One["Engine GroupBy:同一個 null 組"]
X2 --> One
Y1 --> One
One --> Points["1,497 × 10% → 149 點"]
class X,Y input
class X1,X2,Y1 key
class One neutral
class Points result
classDef input fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef key fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef neutral fill:#fbf7ef,stroke:#b49b78,color:#493b2d
| 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。
flowchart TB
Same["相同商品與門檻:總額 1,497,比例 10%"] --> Mode{"計算方式與 GroupKey"}
Mode --> M["Merged:整張 TG 一組 → 149"]
Mode --> N["Independent:全 null,一組 → 149"]
Mode --> T2["Independent:TS1、TS2,兩組 → 148"]
Mode --> T3["Independent:三個 key,三組 → 147"]
class Same input
class Mode key
class M,N,T2,T3 result
classDef input fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef key fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef neutral fill:#fbf7ef,stroke:#b49b78,color:#493b2d
若商品經過前面的折扣計算,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 識別值。
flowchart TB
Front["FrontendAPI 建立 Engine 商品"] --> Cart["cart-calculate:未填 TS GroupKey"]
Front --> Basket["basket-calculate:共用 base,未填 TS GroupKey"]
Front --> Exchange["換價流程:組合商品組號或 CartExtendInfoItemGroup"]
Front --> Redeem["點數折抵:SKU 與選項資訊"]
class Front input
class Cart,Basket key
class Exchange,Redeem neutral
classDef input fill:#edf3e4,stroke:#7a9a5e,color:#3f542d
classDef key fill:#f5e8d3,stroke:#a06a41,color:#65442c
classDef result fill:#e8f1dc,stroke:#6f9250,color:#3f542d
classDef neutral fill:#fbf7ef,stroke:#b49b78,color:#493b2d


