先收斂出一份「一張訂單所需的全部事實」,再落地成 Order。
結帳 controller 是電商裡最容易爛掉的地方。它要處理表單驗證、超商門市、發票欄位、運費、付款方式、訂單建立、寄信、導向金流⋯⋯一路寫下來,八百行起跳。
我讓它不爛的方法是:在寫進資料庫之前,先把所有東西收斂成一個物件。
CheckoutInput(顧客在結帳頁填的)
+
CartState(跑完計價 pipeline 的購物車)
+
ShippingQuote(依地區與配送方式算出的運費)
↓
CheckoutState
↓
Order(寫 DB)
CheckoutInput 只承載顧客意圖:聯絡資訊、收件地址、配送方式、付款方式、發票。裡面沒有任何一個金額欄位。 表單不該決定價錢。
CheckoutState 是三者合體,它負責最後兩個數字:
public function merchandiseTotal(): int // 商品應付(促銷後、未含運費)
public function grandTotal(): int // merchandiseTotal + shippingFee
到這一步,這個物件就包含了「一張訂單所需的全部事實」。接下來寫 DB 的那段程式碼,只做一件事:照抄。
'items_subtotal' => $cart->itemsSubtotal(),
'discount_total' => $discountTotal,
'shipping_fee' => $checkoutState->shippingFee(),
'total' => $checkoutState->grandTotal(),
沒有任何計算發生在寫 DB 的那一層。persistOrder() 裡面不該出現加減乘除。它只是把算好的事實搬進資料表。
這個切法帶來幾個實際的好處:
訂單是歷史快照。 落地時商品名稱、單價、運費、付款方式的顯示名稱全部存下來,之後商品改名改價,這張舊訂單看起來還是當時的樣子。不要在訂單頁 join 回去讀現在的商品資料。
不信任畫面。 結帳送出時,我不看畫面傳回來的金額,而是從 session 重建購物車、重新跑一次完整 pipeline。畫面上的數字只是顯示用的,最終金額一律以伺服器重算的為準——不然你就等著被人改 hidden input。
運費算在最後。 免運門檻要用「促銷後的商品應付金額」當基準,所以運費必須等 pipeline 跑完才能算。順序是:先算促銷、再算運費、最後加總。
結帳的複雜度本身沒辦法消失,但你可以把它擠到一個地方:在建立 CheckoutState 之前,什麼都可以髒;建立之後,只剩下照抄。