前面幾篇的訂單狀態機裡,Order 有一個 SHIPPING 狀態。但「出貨」這件事,遠比一個狀態複雜——因為一張訂單常常不是一次出完的

冷凍的跟常溫的要分開寄、不同倉的貨要各自出、超商取貨跟宅配走不同物流商。一張訂單底下,其實是好幾個各自獨立的「包裹」在跑。這篇拆這個粒度問題,以及一個我覺得很值得講的現場:這套系統的物流,是一個遷移只做了一半的雙軌狀態。

分批出貨:訂單一個狀態,包裹各有各的命

設計上,一張訂單會按「倉庫 × 溫層」拆成多個出貨子單(DeliveryGroup)。冷凍品從冷凍倉出、常溫品從常溫倉出,各自是一個 DeliveryGroup,各自有自己的 7 態物流生命週期(處理中 → 已出貨 → 運送中 → 派送中 → 已送達 …)。

這就帶出一個粒度落差的問題:Order 只有一個粗粒度的 SHIPPING,但底下 A 包裹可能已經「已送達」、B 包裹還在「運送中」。 訂單這台狀態機看到的是「還在出貨」,但真實世界是「出了一半」。

這個落差怎麼處理,是分批出貨設計的核心。「這張訂單算不算送達完成」不能只看 Order 狀態,要看底下所有 DeliveryGroup 是不是都到齊了。而「收貨」這件事本身,還要區分是物流商回報的、客人自己確認的、還是系統自動判定的——所以收貨時間 received_at 配一個 receive_source(來源),這是典型的「用事實欄位記錄、而不是塞成狀態」(訂單狀態機那篇講過的原則)。

誠實的現場:一個遷移做了一半的雙軌

這一段我想很誠實地講,因為它比任何漂亮的設計圖都真實。

這套系統的物流其實有兩套模型並存

  • 舊的 Shipment:最早的出貨模型。
  • 新的 DeliveryGroup:後來要支援分批出貨才設計的、有完整 7 態生命週期的新模型。

問題是——遷移只做了一半。實際去追 code 會發現:新的 DeliveryGroup 的 7 態,實際上只有「建立時分組」那一步在動,後面 6 個狀態幾乎沒有 code 真的去寫入它們;而後台真正在操作出貨、查貨態的,還是舊的 Shipment

換句話說:新模型鋪好了鐵軌,但火車還在舊軌上開。 DeliveryGroup 負責「建單時把訂單拆成幾組」,之後就靜止了;真正的物流狀態變化,活在舊的 Shipment 上。

這揭示一個做系統遷移一定會遇到的真相:新舊模型並存的時候,怎麼判斷哪個才是 source of truth(真相來源)? 答案不是看「哪個比較新、設計比較好」,是看「哪個實際被讀寫」。設計得再漂亮的新模型,只要 service 層沒接完,它就只是一堆 schema,真相還在舊的那邊。

順帶抓到的另一個過渡病

同一個雙軌問題還帶出一個小坑:Order 進 SHIPPING 有兩條驅動路徑——出庫單確認出貨會轉、後台手動發貨也會轉。兩條路徑並存,就是「新舊過渡沒收乾淨」的典型症狀。這跟庫存那邊「三條扣庫存路徑並存」(下一篇會講)是同一種病:重構做到一半,新路徑加上去了,舊路徑沒拔掉。

這種過渡帶不是「寫得爛」,是真實系統演化的常態——需求先落地、清理慢慢來。但它要被看見、被標記,不然下一個接手的人會以為那是設計,照著抄。

收:出貨物流的三個設計點

  1. 分批出貨的粒度落差:訂單一個粗粒度狀態、包裹各有各的生命週期——「訂單完成」要看所有子單到齊,不是看訂單狀態。
  2. 收貨用事實欄位不用狀態:時間 + 來源,別把每個收貨方式塞成狀態。
  3. 遷移做一半怎麼判真相:新舊並存看「哪個被實際讀寫」,不是看哪個設計漂亮。新模型沒接完就只是 schema。

接下來往哪走

  • 庫存預佔 — 同款「多路徑並存」的過渡病,在扣庫存這邊更明顯
  • 訂單狀態機 — 為什麼物流狀態要獨立一台,不塞進訂單
  • strangler fig — 新舊模型並存怎麼收斂的通用方法