達標提示是對購物車的唯讀投影,不是另外寫一份判斷。
購物車頁很常需要這種提示:
✓ 滿 3000 折扣 10%
滿 5000 送旅行組,還差 $1,400
這個功能看起來很單純,所以通常是前端工程師或某個趕時間的人,隨手在 blade 裡寫一段判斷:
@if ($subtotal >= 5000)
然後過一陣子客服就會收到客訴:「你們網站說我滿額有送贈品,我結帳完沒有。」
原因幾乎都一樣:提示用的基準金額,跟計價用的基準金額不是同一個。 例如提示算的是「含加價購」的小計,計價規則算的是「不含加價購」的商品金額。差一個加價購的 99 元,客戶就跨過了門檻,但實際入帳沒有。
我的處理方式是,把這個提示當成對購物車的唯讀投影,寫成一個純函式,跟計價規則放在一起:
PromotionStatus::forCart($promos, $state): PromotionProgress[]
每一筆 progress 就四件事:文案、有沒有達標、還差多少、門檻是多少。
description "滿 5000 送旅行組"
met false
remaining 1400
threshold 5000
關鍵在第一行:
$base = $state->linesSubtotal();
跟計價規則用的是同一個方法。 滿額折扣用 linesSubtotal()、滿額禮用 linesSubtotal()、達標提示也用 linesSubtotal()。三個地方不可能算出不同答案,因為它們讀的是同一顆數字。
我在 CartState 上刻意分了兩個名字,就是為了這件事:
itemsSubtotal() // 商品 + 加價購,用來算應付金額
linesSubtotal() // 只算商品,用來當所有滿額門檻的基準
為什麼加價購不算進門檻?因為加價購是「因為你達標了所以給你的優惠」,讓它反過來幫你達標,邏輯上是繞回去的,而且客戶會拿 99 元的加購品去湊 5000 的贈品門檻。
至於「不含加價購」這個決策本身對不對,其實不是重點。重點是這個決策只存在一個地方,而畫面上的提示是從那個地方投影出來的,不是另外抄一份。
順帶一提,這個投影還會回傳 kind(discount / gift),前端拿它決定要顯示哪個 icon。文案跟排序在後端決定,前端只負責畫。前端不做促銷判斷——這是我的底線。