從 DB 撈出來的促銷設定,先組成一份 Catalog,再丟進計價邏輯。
這是一個很小的設計,但它決定了你的促銷模組能不能長期維護。
促銷規則需要參數:門檻是多少、折幾折、送什麼贈品、加購價多少。這些參數住在資料庫,而且會被後台改來改去。
很多人的寫法是,在促銷規則裡面直接查 DB:
// 不要這樣
$tiers = ThresholdDiscount::orderBy('threshold')->get();
一旦這樣寫,計價邏輯就跟資料庫黏死了。測試要開 DB、要寫 seeder;想做一個「試算這筆訂單」的後台工具,你得先在 DB 裡塞假資料。
我的做法是中間放一份 PromotionCatalog——這次購物適用的所有促銷設定,一個唯讀的物件:
new PromotionCatalog(
thresholdTiers: [...], // 滿額折扣階梯
thresholdGifts: [...], // 多檔滿額禮
coupon: ..., // 一次購物一組
addOnOffers: [...], // 多檔加價購
pointsProgram: ..., // 一次購物一組
);
有一個細節我很在意:這裡的參數順序,刻意對齊 pipeline 的執行順序。滿額折扣 → 滿額禮 → 折扣碼 → 加購 → 點數。讀這個類別的人,順手就理解了計算順序。
然後另外做一個 PromotionCatalogBuilder,它唯一的工作是「DB model → Promotion DTO」的對應:
public static function build(?string $appliedCouponCode = null): PromotionCatalog
這個 builder 裡面沒有任何一行數學。沒有 if 金額 >、沒有 * 0.9、沒有 floor()。它只是撈資料、換個形狀。數學全部留在計價規則裡。
分開之後你會得到幾個很實際的東西:
一句話:builder 負責回答「這次有哪些活動」,rules 負責回答「這些活動怎麼算錢」。 兩件事分開,促銷模組就不會爛掉。