促銷規則的型態一律是:(設定, CartState) → CartState。
上一篇講 pipeline 的形狀,這篇講裡面那些規則怎麼寫。
我的規定很簡單,每條促銷規則都是一個普通的靜態純函式,型態長得一模一樣:
public static function thresholdGift(ThresholdGift $promo, CartState $state): CartState
public static function couponCode(CouponDiscount $promo, CartState $state): CartState
public static function addOnOffer(AddOnOffer $promo, CartState $state): CartState
吃一份設定、吃目前的購物車狀態,回傳新的購物車狀態。不碰 DB、不碰 session、不碰 request、不寫 log。
這帶來三個好處。
第一,不符合條件就原樣回傳。
if ($state->linesSubtotal() < $promo->threshold) {
return $state;
}
規則不需要知道自己排第幾個、後面還有誰。它只需要看一眼現在的狀態,決定要不要出手。這讓每條規則都可以單獨閱讀、單獨理解。
第二,折扣不是「改金額」,是「追加一筆調整」。
規則不會去把 total 減掉某個數字。它是 append 一筆 Adjustment:
return $state->withAdjustment(new Adjustment("滿 {$best->threshold} 折扣 {$offPercent}%", -$discount));
一個 label、一個金額,就這樣。購物車的應付金額永遠是「商品小計 + 所有調整的總和」,是推導出來的,不是被誰改出來的。
所以金額明細可以直接把 adjustments 逐筆列出來給客戶看:
商品小計 3,600
滿 3000 折扣 10% -360
折扣碼 WELCOME 10% -324
而且這份調整清單,落地的時候就是 order_discounts 的每一列。在記憶體裡我用負數方便相加,寫進 DB 的時候取絕對值存正整數,跟訂單金額欄位的規格一致。
第三,測試不用開資料庫。
促銷規則是電商最需要測試、也最容易算錯的地方。因為它是純函式,測試就是「準備一個 CartState、呼叫、斷言」。不用 factory、不用 seeder、不用 migration。
最後提一個實務上的小陷阱:折扣的基準要講清楚。我的折扣碼是這樣寫的:
$base = max(0, $state->linesSubtotal() + $state->adjustmentsTotal());
基準是「已經套用前面折扣之後」的商品金額。所以滿額 9 折之後再打折扣碼 9 折,結果是 81 折,是連乘、不是相加。這種事沒寫清楚,行銷跟工程師的認知一定會不一樣。我的做法是把它寫在那條規則的註解裡——因為那正是唯一決定這件事的地方。