Refund-WebStores
🧾 退貨/取消 Qty 與 TotalPayment 退費計算完整分析
MobileWebMall 前台會員訂單在「部分取消」「部分退貨」「補退款」情境下,實際數量與已付金額如何被重新計算,以及運費類型如何影響退費判斷。
架構總覽
用在:貫穿「訂單明細頁」與「申請退貨頁」的整條退費流程
各層職責
| 層級 | 檔案 | 職責 |
|---|---|---|
| 訂單明細組裝 | MemberTradesOrderService.ArrangePartialQtyCancelInfo | 依取消 / 退貨 / 補退資料,重新計算 TS 的實際 Qty 與 TotalPayment |
| 部分退款金額判斷 | MemberTradesOrderService.ArrangeRefund 相關方法 | 計算 PartialRefundAmount、TotalRefundAmount,決定是否顯示退款資訊區塊 |
| 退貨資格檢查 | ReturnGoodsRequestService | 檢查部分退貨是否開放、活動/加價購限制、退款資訊蒐集條件 |
| 運費類型 | ShopShippingType / ShopShippingTypeRepository | 依 ShopShippingTypeId 取得 ShopShippingType_FeeTypeDef,描述商店運費規則種類 |
| 訂單查詢組裝 | GetMemberTradesOrderProcessor / GetMemberArchiveTradesOrderProcessor | 撈取 TS 明細時一併帶出 OrderSlaveFlowCancelQty、OrderSlaveFlowOrderSpreadsAmount 等欄位 |
可以把整個流程想成三層:先算「剩多少」(Qty / TotalPayment)→ 再算「該退多少」(PartialRefundAmount / TotalRefundAmount)→ 最後看「運費要不要動」(FeeTypeDef)。
❓ 為什麼要先算「剩多少」?
一筆訂單子單(TS)從下單到現在,中間可能已經發生過:
- 部分數量取消(
OrderSlaveFlowCancelQty)— 例如缺貨、使用者申請取消部分件數 - 部分退貨(
OrderSlaveFlowReturnGoodsQty)— 商品出貨後使用者申請退貨 - 補退價差(
OrderSlaveFlowOrderSpreadsAmount)— 換規格、活動重新計算導致的正負金額調整
這些事件可能只發生一種,也可能先後疊加發生(例如先取消 1 件、出貨後又退貨 1 件)。但資料庫裡的 Qty、TotalPayment 存的是「最初下單當下」的原始值,不會自動反映這些後續異動。
所以「先算剩多少」的意思是:拿原始下單的 Qty / TotalPayment,把已經發生過的取消、退貨、補退全部扣除或加回,還原出「現在這個當下,這筆訂單真正還剩下幾件商品、值多少錢」。這一步做完,後面才有正確的基礎去判斷「該退多少錢」——如果沒有先做這一步,數量和金額都會是錯的(沒扣掉之前已經取消/退過的部分)。
案例資訊
訂單編號:MG260204P00004 TS Id:16434
此案例用來驗證「部分取消 + 部分退貨 + 補退款」同時存在時,實際數量與已付金額的重算結果是否正確。
運費規則 ShopShippingType_FeeTypeDef
用在:訂單明細頁「運費退款資訊」區塊,決定要不要顯示 / 要不要連動處理運費
訂單子單(orderSlave)上帶有 ShopShippingTypeId,透過此 Id 可對應到商店設定的 ShopShippingType_FeeTypeDef,用來標示這筆訂單當初適用的運費計算規則。退貨/退費流程在判斷「運費是否需要一併退還」、「是否因退貨導致低於免運門檻」時,會需要參考這個欄位。
| 值 | 說明 |
|---|---|
Fixed | 固定運費:不論金額或重量,收取固定運費 |
Free | 免運費:全店/該商品一律免運 |
OverPrice | 滿額免運:訂單金額達到門檻即免運,退貨後若金額低於門檻需另行處理運費補收/退還 |
PriceRule | 多階運費:依訂單金額區間套用不同運費 |
WeightBilling | 重量計費:依商品總重量計算運費 |
運費是掛在「整張訂單」身上,不是每筆退貨商品各自帶運費。一張訂單通常有很多筆 TS(每個商品一筆),但運費只收一次,會「依附」在其中某一筆特定的 TS 上(不保證一定是第一筆)。所以要判斷運費退款狀態時,得先用訂單主單編號(tmCode)呼叫 csp_GetSalesOrderFeeTradesOrderSlaveCode,才能查出「這張訂單的運費到底掛在哪一筆 TS」;找到那筆 TS 後,再去確認它有沒有一張 RefundRequest_SourceDef == SalesOrderFee 的退款單,藉此決定畫面要不要顯示運費退款資訊。
運費退款判斷流程(4 步驟)
使用者對某張訂單(TM,例如 MG260204P00004)申請退貨或取消。
用訂單主單編號呼叫 csp_GetSalesOrderFeeTradesOrderSlaveCode,回傳「運費實際掛在哪一筆 TS」。
用上一步找到的 TS Code,查 RefundRequest 表是否存在 SourceDef == SalesOrderFee 的退款單。
有 → 顯示「運費退款資訊」區塊;沒有 → 不顯示。
核心公式 Qty 與 TotalPayment 重算
用在:會員「訂單明細頁」商品清單的數量/金額欄位
訂單明細載入後,MemberTradesOrderService.ArrangePartialQtyCancelInfo 會針對每一筆 TradesOrderSlave(TS),把「原始購買數量/金額」扣掉「取消」「退貨」,再加回「補退款價差」,得到目前真正生效的數量與金額。
//// 實際Qty為一開始購買的Qty 扣除 取消的Qty,退貨的Qty entity.Qty = entity.Qty - entity.OrderSlaveFlowCancelQty - entity.OrderSlaveFlowReturnGoodsQty; //// 補退單為正數,取消金額、退款金額為負數,扣除之後便為實際付款金額 entity.TotalPayment = entity.TotalPayment - entity.OrderSlaveFlowCancelTotalPayment - entity.OrderSlaveFlowReturnGoodsTotalPayment + entity.OrderSlaveFlowOrderSpreadsAmount; //// 補退金額
欄位定義
| 欄位 | 意義 | 對「實際金額」的影響 |
|---|---|---|
Qty | TS 原始購買數量(DB 原值) | 計算基準 |
OrderSlaveFlowCancelQty | 已取消的數量 | 正數(相減) |
OrderSlaveFlowReturnGoodsQty | 已退貨的數量 | 正數(相減) |
TotalPayment | TS 原始應付金額(DB 原值) | 計算基準 |
OrderSlaveFlowCancelTotalPayment | 取消對應金額 | 讓「實際」變少(扣除已取消部分) |
OrderSlaveFlowReturnGoodsTotalPayment | 退貨對應金額 | 讓「實際」變少(扣除已退貨部分) |
OrderSlaveFlowOrderSpreadsAmount | 補退款價差(改規格/活動重算) | 補收為正 → 讓「實際」變多;退款為負 → 讓「實際」變少(唯一雙向的變數) |
這裡是最容易搞混、也最需要注意的地方:「相減」之後結果是變少還是變多,完全取決於 CancelTotalPayment / ReturnGoodsTotalPayment 這兩個欄位本身在資料庫裡存的是正數還是負數——而這是由另一支負責「產生取消 / 退貨事件」的程式(通常在訂單處理服務那端)寫入的,不在這支重算程式裡,無法只看這行公式就完全確定。
但依照「取消 / 退貨後,金額理論上應該要變少」這個業務常理反推:這兩個欄位正常情況下應該以正數(代表取消/退貨對應的金額)代入運算,這樣公式才會是「真的在扣錢」,邏輯才會跟 Qty 公式(Qty − CancelQty − ReturnGoodsQty,同樣是正數相減)一致。
計算後的 Qty / TotalPayment 即為「目前這筆 TS 實際生效(未取消、未退貨)的數量與已付金額」,會顯示在會員訂單明細頁,也作為後續退款金額計算的基礎。
範例試算(純取消 + 退貨,無補退價差)
原始 Qty = 3,TotalPayment = 900(單價 300);取消 1 件(CancelQty=1,對應金額 300),退貨 1 件(ReturnGoodsQty=1,對應金額 300),此情境沒有補退價差:
Qty = 3 - 1 - 1 = 1 // 剩 1 件仍有效 TotalPayment = 900 - 300 - 300 + 0 = 300 // 剩下 1 件應付 300 元,符合直覺:變少了
範例試算(加上補退價差)
承上例,若這 1 件剩餘商品因活動重新計算,須額外補收 50 元(OrderSpreadsAmount = +50):
TotalPayment = 900 - 300 - 300 + 50
= 350 // 因為有補收價差,才會比單純扣除後的 300 還要多
這就是為什麼補退價差是「唯一可能讓實際金額超過『扣除取消退貨後』數字」的變數——因為它代表另一筆獨立的加價或退款事件,跟單純的取消/退貨方向不一定相同。
退款判斷 PartialRefundAmount / TotalRefundAmount
用在:會員「訂單明細頁」顯示退款金額欄位
這個章節算出來的數字,就是使用者在「會員中心 → 訂單明細」頁面上看到的退款金額欄位。因為退貨從「使用者申請」到「退款單正式產生」中間有一段流程時間差,畫面不可能讓使用者等到流程跑完才顯示金額,所以系統依「退款單是否已經正式產生」分成兩種情境,決定當下要顯示哪一個數字:
🕒 情境一:申請後、退款單還沒產生
後端還在跑審核/驗退/ERP 產單流程,資料庫還沒有正式的 RefundRequest。畫面改用 PartialRefundAmount(估算值),先讓使用者知道大概能拿回多少錢。
✅ 情境二:退款單已正式產生
系統已經有真正的退款紀錄(甚至可能已退款完成)。畫面改用 TotalRefundAmount(真實值),直接取退款單上的實際金額,比估算值更準確。
🔄 顯示邏輯
同一個「退款金額」欄位,會依當下有沒有退款單,自動在這兩個數字之間切換,對使用者來說永遠只看到一個「目前最準確」的金額。
除了重算 Qty / TotalPayment,訂單明細還需要判斷「這筆 TS 是否存在部分退款」,並算出對應金額顯示給使用者。判斷依據有兩個旗標:
//// 是否存在補退價差(負數代表需要退款) ts.IsExistsOrderSpreadAmount = ts.OrderSlaveFlowOrderSpreadsAmount < 0; //// 是否為 TS 數量部分取消(取消數量 > 0 且小於購買數量) ts.IsExistsPartialQtyCancel = this.IsPartialQtyCancel(ts.OrderSlaveFlowCancelQty, ts.Qty); //// 判斷是否有部分取消(TS 部分數量取消或是補退),有的話要計算部分取消金額 (不需管退款單) if (ts.IsExistsOrderSpreadAmount || ts.IsExistsPartialQtyCancel) { ts.PartialRefundAmount = -1 * (ts.OrderSlaveFlowCancelTotalPayment) + ts.OrderSlaveFlowOrderSpreadsAmount; } //// 有實際退款單時,改用退款單金額加總作為總退款金額(排除運費退款單) ts.TotalRefundAmount = -1 * tsRefundListExceptSalesOrderFee.Sum(x => x.RefundRequest_Amount);
IsPartialQtyCancel 判斷邏輯
public bool IsPartialQtyCancel(decimal orderSlaveFlowCancelQty, decimal qty) { if (orderSlaveFlowCancelQty == 0) { return false; // 完全沒取消,不算部分取消 } if (qty > orderSlaveFlowCancelQty) { return true; // 取消數量小於購買數量 → 為部分取消 } // qty <= 取消數量 → 視為全部取消,非「部分」 ... }
PartialRefundAmount
依「取消金額 + 補退價差」推算的部分退款金額,不需要有實際退款單即可顯示,用於 UI 上先行提示金額。
TotalRefundAmount
當已產生退款單時,改採退款單(RefundRequest)實際金額加總,排除運費類退款單,數字更貼近真實退款結果。
運費退款
另外獨立以 csp_GetSalesOrderFeeTradesOrderSlaveCode 找出運費掛載的 TS,比對 SourceDef == SalesOrderFee 的退款單,計算 totalSalesOrderFee 並決定是否顯示運費退款區塊。
前置檢查 是否允許部分退貨
用在:使用者「申請退貨」頁面勾選商品時的前置檢查
在計算退費金額之前,ReturnGoodsRequestService 會先確認商店是否開放「部分退貨」,此設定即文件中提到的 detail.IsPartialReturnGoodsEnabled。
ArrangeShopPartialSetting 將後台設定的 shopPartialSetting.IsPartialReturnGoodsEnabled 帶入 detail.IsPartialReturnGoodsEnabled。
ValidatePartialReturn 直接 return,不再檢查活動/加價購限制,允許使用者自由勾選要退貨的商品子單。
若訂單含活動(HasActivity)或加價購(HasPurchaseExtra),且非所有商品都允許部分退(HasPartialReturn),則必須連同「不可部分退」的必退商品一起勾選,否則丟出例外「不允許部分退貨」。
對於未攤提到所有商品的活動(例如第 N 件折扣),會額外呼叫 CheckTradesOrderSlaveWithUnAllocatePromotionEngine 驗證勾選組合是否合法。
禮物卡另有獨立限制:confirmEntity.IsPartialReturnBlockedByGiftCard() 為 true 時,直接禁止部分退貨,優先於其他所有判斷。
🤔 商業原因:商家為什麼會不允許部分退貨?
這些檢查不是技術上做不到,而是背後有實際的商業/會計考量:
🎁 促銷活動資格
「滿 3 件 8 折」「買 2 送 1」這類優惠是依「整組」計算的。只退掉其中 1 件,剩下商品就不再符合門檻,商家得重新拆算折扣、追回多給的優惠,計算複雜又易出錯,乾脆規定要退就整組一起退。
🛍️ 加價購綁定主商品
加價購商品是「因為買了主商品才用特價買到」。主商品退了、加價購商品卻留著,等於使用者用特價買到一個不該單獨享優惠的商品,所以主商品退貨時必須連同加價購一起退。
📦 組合包 / 套裝定價
套裝商品是用「整組」價格販售的,拆開單獨退貨會讓剩下商品的「單價」失去依據,退款金額該算多少容易產生爭議。
🎟️ 禮物卡法規 / 防詐考量
多數地區法規對禮物卡退換較嚴格(通常不可退現金),加上部分退貨可能被利用來反覆套現,商家索性直接禁止禮物卡部分退貨。
⚙️ 商家系統 / 營運能力
部分退貨需要額外的驗貨、拆單、重開發票等作業,系統或人力有限的商店,寧可用「全部退或都不退」的簡單政策降低出錯與客服成本——這就是最上層開關 IsPartialReturnGoodsEnabled 的由來。
💡 一句話總結
只要退貨會打亂「已核算好的優惠、套裝定價、法規限制」,或增加商家作業成本,商家就傾向禁止部分退貨、要求整筆一起處理。
資料鏈 四張表的關係
用在:串起「訂單商品」→「退貨申請」→「ERP 退貨處理」→「退款」的整條資料鏈
前面幾個章節提到的所有欄位(OrderSlaveFlowCancelQty、OrderSlaveFlowReturnGoodsTotalPayment…),其實都是同一張「流程表」上的欄位。這張表叫 OrderSlaveFlow,它才是真正把 TradesOrderSlave、ReturnGoodsRequest、ReturnGoodsOrderSlave、RefundRequest 這四張表串在一起的「樞紐(Hub)」。以下依實際程式碼逐一拆解。
| 資料表 | 白話角色 | 所在資料庫 |
|---|---|---|
TradesOrderSlave(TS) | 訂單裡的「一筆商品子單」,退費計算的最小單位 | WebStore DB |
OrderSlaveFlow | 每筆 TS 專屬的「狀態與流程紀錄表」,本身沒有業務金額,但存了所有指向其他三張表的關聯 Id | WebStore DB |
ReturnGoodsRequest | 使用者在前台按下「申請退貨」當下產生的紀錄,代表「使用者申請了什麼」 | WebStore DB |
ReturnGoodsOrderSlave | 申請單被轉入 ERP 後,倉庫/供應商實際處理退貨的明細,代表「貨真的怎麼被驗退、誰負責」 | ERP DB |
RefundRequest | 不論是取消、退貨、運費、逾期未領,只要有錢要退,最終都會落地成一筆退款單,代表「錢實際退了多少、退去哪」 | ERP DB |
關聯欄位實際長什麼樣子(程式碼實證)
直接看 OrderSlaveFlow.cs 這張表的欄位定義,可以清楚看到它同時持有「退貨申請」跟「ERP 退貨明細」兩邊的關聯 Id 與狀態鏡像:
// 指向 WebStore.ReturnGoodsRequest(使用者申請的那筆) public Nullable<long> OrderSlaveFlow_ReturnGoodsRequestId { get; set; } public string OrderSlaveFlow_ReturnGoodsRequestStatusDef { get; set; } // 指向 ERP.ReturnGoodsOrderSlave(ERP 實際處理退貨的那筆) public Nullable<long> OrderSlaveFlow_ReturnGoodsOrderSlaveId { get; set; } public string OrderSlaveFlow_ReturnGoodsOrderSlaveStatusDef { get; set; } // 退貨數量/金額,就是前面幾章一直用到的欄位 public short OrderSlaveFlow_ReturnGoodsQty { get; set; } public decimal OrderSlaveFlow_ReturnGoodsTotalPayment { get; set; }
重點:OrderSlaveFlow 跟 TradesOrderSlave 是 1 對 1(透過 OrderSlaveFlow_TradesOrderSlaveId),但它同時「認識」ReturnGoodsRequest 跟 ReturnGoodsOrderSlave 各自的 Id——因為這兩張表分屬不同資料庫(WebStore/ERP),彼此不能直接下 SQL JOIN,所以都要繞道 OrderSlaveFlow 才查得到彼此。
實際寫入時序(來自 ReturnGoodsRequestRepository.CreateReturnGoodsRequest)
使用者在前台按下「送出退貨申請」時,程式碼實際執行的兩個步驟如下(節錄自 WebStore/DA/WebStoreDBV2/Repositories/ReturnGoodsRequestRepository.cs):
// 1. 建立退貨申請單 foreach (var request in insertedList) { context.ReturnGoodsRequest.Add(request); } context.SaveChanges(); // 2. 回填 OrderSlaveFlow:把剛剛新增的 ReturnGoodsRequest_Id 寫回這筆 TS 的流程表 flow.OrderSlaveFlow_ReturnGoodsRequestId = request.ReturnGoodsRequest_Id; flow.OrderSlaveFlow_ReturnGoodsRequestStatusDef = ReturnGoodsRequestStatusEnum.WaitingToTrans.ToString(); // 待轉單 flow.OrderSlaveFlow_StatusDef = MemberTradesOrderOrderSlaveFlowStatusEnum.ReturnGoodsRequesting.ToString(); flow.OrderSlaveFlow_CanCancel = false; // 申請退貨後,這筆 TS 就不能再取消了
新增一筆 ReturnGoodsRequest(WebStore DB),記錄退貨原因、數量、退款帳戶等使用者填的資訊。
把新增的 ReturnGoodsRequest_Id 寫回這筆 TS 對應的 OrderSlaveFlow,狀態設為「待轉單(WaitingToTrans)」,同時把 CanCancel 等操作旗標關閉,避免使用者同時申請取消。
背景排程/服務把「待轉單」的申請轉進 ERP,ERP 端建立正式的 ReturnGoodsOrderSlave(實際驗退、供應商處理的明細),並把它的 Id 回填到同一筆 OrderSlaveFlow_ReturnGoodsOrderSlaveId。
ERP 確認退貨完成後,建立一筆 RefundRequest,用 RefundRequest_TradesOrderSlaveCode 對應回同一筆 TS,並將 RefundRequest_SourceDef 標記為 ReturnGoodsOrderSlave,代表「這筆退款是因為退貨事件產生的」。
訂單明細頁偵測到這筆 TS 已經有 RefundRequest,就改用第 4 章介紹的 TotalRefundAmount(退款單真實金額)取代原本的估算值 PartialRefundAmount。
RefundRequest 的來源類型(RefundRequestSourceDefEnum)
退款單不是只因為「退貨」才會產生,程式碼裡定義了 5 種來源,退貨只是其中之一:
| SourceDef | 意義 |
|---|---|
CancelOrderSlave | 取消子單產生的退款 |
ReturnGoodsOrderSlave | 退貨子單產生的退款(本章討論的主角) |
SalesOrderFee | 運費退款(見第 2 章) |
SalesOrderSlave | 逾期未領退款 |
RechargeReceipt | 訂單差價調整退款 |
一張圖看懂整條鏈
(商品子單) → OrderSlaveFlow
(樞紐,1:1 對應 TS) → ReturnGoodsRequest
(使用者申請) → ReturnGoodsOrderSlave
(ERP 驗退明細) → RefundRequest
(實際退款單)
一句話總結:TradesOrderSlave 是「退什麼商品」,OrderSlaveFlow 是「串起一切的樞紐」,ReturnGoodsRequest 是「使用者說要退」,ReturnGoodsOrderSlave 是「ERP 實際處理退貨」,RefundRequest 是「錢真的退了多少」——四張表依序記錄同一件退貨事件在不同階段、不同系統裡的樣貌。
🔁 補充:同一筆 TG,退貨/取消是怎麼「同步」到 ERP 的?
用在:解釋「為什麼我申請退貨後,訂單頁狀態不是馬上變、而是要等一下」——這一段是 WebStore ↔ ERP 兩個資料庫之間,真正負責「搬資料」的排程機制。
關鍵認知:同步的最小單位不是「這一筆退貨」,而是「整個 TG(訂單群組)裡所有狀態為待轉單的 TS」。排程會一次掃描整個資料庫裡所有「待轉單」的 CancelRequest 與 ReturnGoodsRequest,不是只針對你剛剛送出的那一筆。
WebStore DB 寫入 ReturnGoodsRequest(或 CancelRequest),並把對應的 OrderSlaveFlow 狀態改為 WaitingToTrans(待轉單)。這一步只發生在 WebStore DB,ERP 還完全不知道這件事。
這是一支實際存在的 SQL Server Agent Job,核心動作是呼叫 ERPDB.dbo.csp_ImportWebStoreDBRequestDataToERPDB,透過 Linked Server 把 WebStoreDB 裡所有 ReturnGoodsRequest_StatusDef = 'WaitingToTrans' 和 CancelRequest_StatusDef = 'WaitingToTrans' 的資料,整批 INSERT 進 ERPDB 對應的 ReturnGoodsRequest / CancelRequest 表,並回寫 WebStoreDB 的 OrderSlaveFlow。
-- csp_ImportWebStoreDBRequestDataToERPDB.sql(節錄邏輯) SELECT ... FROM [WebStoreDB].[dbo].[ReturnGoodsRequest] WHERE ReturnGoodsRequest_StatusDef = 'WaitingToTrans'; -- 整批寫入 ERPDB,並回填 OrderSlaveFlow 狀態 INSERT INTO [dbo].[ReturnGoodsRequest] (...)
Job 接著會查詢 ERPDB 裡所有 ReturnGoodsRequest_StatusDef = 'WaitingToTrans'(取消單同理)的資料——注意這裡查的是「全庫」,不是限定某個 TG——把每一筆的 ReturnGoodsRequest_Id 個別包成一個 Task,塞進 NMQV2DB.dbo.Task 表,指定要交給 ReturnGoodsOrderBatchInsert(退貨)或 CancelOrderProcess(取消)這兩支背景 Worker 處理。
這一步的實作是獨立的 ERP 端 Worker 服務(不在本 mweb repo 內,屬於 ERP 系統程式碼),依 Task 內容把退貨單轉成正式的驗退明細 ReturnGoodsOrderSlave,並視付款方式產生 RefundRequest(RefundRequest_SourceDef = ReturnGoodsOrderSlave)。
透過 csp_SyncERPDBReturnGoodsRequestStatusToWebStoreDB,把 ERPDB 端 ReturnGoodsRequest 最新的狀態(StatusDef、IsClosed 等)透過 Linked Server 寫回 WebStoreDB 同一張表的同一筆資料,前台訂單頁下次查詢時就能看到最新狀態。
| 階段 | 發生在哪裡 | 關鍵物件 | 顆粒度 |
|---|---|---|---|
| 使用者申請 | WebStoreDB | ReturnGoodsRequest / CancelRequest | 單筆 TS |
| 匯入 ERPDB | WebStoreDB → ERPDB | csp_ImportWebStoreDBRequestDataToERPDB | 整批(所有待轉單資料,非單一 TG) |
| 丟入非同步佇列 | ERPDB → NMQV2DB | Task(Job: ReturnGoodsOrderBatchInsert / CancelOrderProcess) | 每筆 ReturnGoodsRequest_Id / CancelRequest_Id 各自一個 Task |
| ERP 實際處理 | ERP Worker(ERP 系統程式碼) | ReturnGoodsOrderSlave / RefundRequest | 單筆 Task |
| 狀態寫回 | ERPDB → WebStoreDB | csp_SyncERPDBReturnGoodsRequestStatusToWebStoreDB | 單筆 ReturnGoodsRequest_Id |
對之前「TG 觸發轉單」說法的修正:先前提到「轉單」是以整個 TG 為單位觸發,這在金流付款成功轉單(TransToERPTmpV2Process/TransferOrderProcessor)的情境下正確——那是以 tradesOrderGroupId 當參數觸發整個訂單的轉單。但退貨/取消走的是另一支排程(csp_ImportWebStoreDBRequestDataToERPDB),它是定期全庫掃描「待轉單」狀態的資料,並非以某個 TG 為觸發單位;只是實務上同一個 TG 底下的多筆退貨/取消申請,很可能剛好在同一次排程執行時一起被撈到、一起轉單,感覺上像是「整批」處理。
🧮 補充:3 筆 TS+固定運費,退貨後 ReturnGoodsOrderSlave 會是幾筆?
用在:釐清「商品退貨」與「運費退款」在資料庫裡其實是兩條平行的軌道,避免誤以為運費也會擠進 ReturnGoodsOrderSlave 多出一筆。
結論:只會是 3 筆,不會是 4 筆。運費退款完全不經過 ReturnGoodsOrderSlave,它走的是另一張獨立的表:SalesOrderFee + 對應的 RefundRequest。
原因可以從實際的資料表設計與 ERP 端 SP 邏輯直接對照出來:
| 商品退貨 | 運費退款 | |
|---|---|---|
| 負責的主表 | ReturnGoodsOrderSlave | SalesOrderFee |
| 對應顆粒度 | 1 筆退貨的 TS = 1 筆 | 1 個 TG(訂單群組)通常只有 1 筆運費資料,不管底下有幾個 TS |
| 關鍵欄位 | ReturnGoodsOrderSlave_TradesOrderSlaveId(指向單一 TS) | SalesOrderFee_TradesOrderSlaveId(運費實際掛載的那個 TS,可能不是第一筆) |
| 退款時寫入 | ERP Worker 處理 Task 後產生 RefundRequest_SourceDef = ReturnGoodsOrderSlave | csp_ReturnSalesOrderFee 直接產生 RefundRequest_SourceDef = SalesOrderFee |
(純商品明細,無運費欄位)
SourceDef = SalesOrderFee
也就是說,一次「全部退貨」的動作,實際上同時觸發兩條互不相干的資料軌道:
- 商品軌道:3 筆
ReturnGoodsOrderSlave,一個 TS 一筆,欄位只描述商品本身(Qty/Price/TotalPayment/TotalDiscount),完全沒有運費相關欄位。 - 運費軌道:運費有自己的主表
SalesOrderFee,退運費是由 ERP 端csp_ReturnSalesOrderFee這支 SP 獨立處理,直接寫入一筆RefundRequest(SourceDef = 'SalesOrderFee',SourceId指向SalesOrderFee_Id),完全不會去新增或修改ReturnGoodsOrderSlave。
這也呼應前面「運費類型定義」章節提到的:查詢運費退款狀態要用 csp_GetSalesOrderFeeTradesOrderSlaveCode 找出運費掛在哪個 TS、再去查 RefundRequest,而不是去看 ReturnGoodsOrderSlave——因為運費本來就不會出現在那張表裡。
⚠️ 補充修正:消費者按下「退貨」,運費會自動一起退嗎?
用在:釐清「運費退款」的觸發者是誰——避免誤以為運費退款是退貨當下由系統自動算好、自動觸發的。
結論:不會。消費者在 mweb 申請退貨,預設只會走商品的 ReturnGoodsOrderSlave 流程,運費不會被自動一併退還。運費退款單(RefundRequest_SourceDef = SalesOrderFee)需要由客服/營運人員在 ERP 後台(SMS)另外手動建立,只有極少數的「整筆訂單全部取消」情境有系統自動化例外。
具體證據如下:
這支 SP 帶有 @userName(操作人員)參數,且註解寫著「放寬客戶新增運費退款單權限」——代表它是由客服/營運人員在 ERP 後台(SMS)手動操作時才會被呼叫,並不是消費者退貨當下由 mweb 系統自動觸發的。
RefundRequestService.cs 裡與運費相關的邏輯,都是在「已經有一筆運費退款單存在」的前提下,去查詢帳戶資料或更新退款狀態——沒有任何「建立」運費退款單的邏輯。
只有在 csp_BatchGeneratePaymentRequest 這支付款單批次產生的 SP 裡,才會呼叫 csp_GenerateCreditCardRefundRequestSalesOrderFee 自動退運費,但觸發條件很嚴格:
-- csp_BatchGeneratePaymentRequest.sql(節錄)
-- BTS22901 依 TG 判斷各 TM 下的所有 TS 都取消時,退運費給消費者
EXEC [dbo].[csp_GenerateCreditCardRefundRequestSalesOrderFee] @groupId;
也就是說,這個自動化只服務「取消」,而且必須是同一個 TM 底下所有 TS 都被取消才會觸發;「退貨」(ReturnGoodsOrderSlave)完全沒有對應的自動退運費邏輯。
| 情境 | 運費會自動退嗎? | 觸發者/機制 |
|---|---|---|
| 消費者退貨(部分或全部商品) | ❌ 不會 | 需客服/營運人員在 ERP 後台手動建立運費退款單(csp_ReturnSalesOrderFee) |
| 同一 TM 底下所有 TS 全部取消 | ✅ 會(僅此特例) | 系統自動化,csp_BatchGeneratePaymentRequest → csp_GenerateCreditCardRefundRequestSalesOrderFee |
| 其他一般取消/部分取消情境 | ❌ 不會 | 同退貨,需人工於後台建立 |
總結:一次退費計算的完整脈絡
- 先確認商店
ShopShippingType_FeeTypeDef運費規則,評估退貨是否影響免運門檻/運費金額。 - 依
IsPartialReturnGoodsEnabled與活動/加價購/禮物卡限制,判斷本次是否允許「部分」退貨。 - 透過
ArrangePartialQtyCancelInfo用「原始 Qty / TotalPayment」扣除「取消、退貨」再加回「補退價差」,得到目前實際生效的數量與金額。 - 再依
OrderSlaveFlowOrderSpreadsAmount、IsPartialQtyCancel判斷是否存在部分退款,估算PartialRefundAmount;若已有正式退款單,改以TotalRefundAmount(退款單金額加總)呈現。 - 運費退款獨立處理,透過
SalesOrderFee類型退款單與 TS 對應碼另行結算。 - 使用者申請退貨/取消後,經由
OrderSlaveFlow樞紐串起ReturnGoodsRequest→ReturnGoodsOrderSlave→RefundRequest四張表,並透過排程csp_ImportWebStoreDBRequestDataToERPDB(全庫掃描待轉單資料,非單一 TG)同步進 ERP,再由 NMQV2 背景 Worker 產生正式退貨明細與退款單。
此訂單用來驗證:當同一筆 TS 同時存在部分取消數量與退貨數量時,Qty/TotalPayment 是否能正確反映「扣除取消與退貨後、且加回補退價差」的最終結果,並確保 UI 顯示的部分退款金額與後續實際退款單金額一致。


