Payment · MemberTradesOrder · 退貨退費

🧾 退貨/取消 Qty 與 TotalPayment 退費計算完整分析

MobileWebMall 前台會員訂單在「部分取消」「部分退貨」「補退款」情境下,實際數量與已付金額如何被重新計算,以及運費類型如何影響退費判斷。

MemberTradesOrderService ReturnGoodsRequestService ShopShippingType_FeeTypeDef
01

架構總覽

用在:貫穿「訂單明細頁」與「申請退貨頁」的整條退費流程

各層職責

層級檔案職責
訂單明細組裝MemberTradesOrderService.ArrangePartialQtyCancelInfo依取消 / 退貨 / 補退資料,重新計算 TS 的實際 QtyTotalPayment
部分退款金額判斷MemberTradesOrderService.ArrangeRefund 相關方法計算 PartialRefundAmountTotalRefundAmount,決定是否顯示退款資訊區塊
退貨資格檢查ReturnGoodsRequestService檢查部分退貨是否開放、活動/加價購限制、退款資訊蒐集條件
運費類型ShopShippingType / ShopShippingTypeRepositoryShopShippingTypeId 取得 ShopShippingType_FeeTypeDef,描述商店運費規則種類
訂單查詢組裝GetMemberTradesOrderProcessor / GetMemberArchiveTradesOrderProcessor撈取 TS 明細時一併帶出 OrderSlaveFlowCancelQtyOrderSlaveFlowOrderSpreadsAmount 等欄位
💡

可以把整個流程想成三層:先算「剩多少」(Qty / TotalPayment)→ 再算「該退多少」(PartialRefundAmount / TotalRefundAmount)→ 最後看「運費要不要動」(FeeTypeDef)。

❓ 為什麼要先算「剩多少」?

一筆訂單子單(TS)從下單到現在,中間可能已經發生過

  • 部分數量取消OrderSlaveFlowCancelQty)— 例如缺貨、使用者申請取消部分件數
  • 部分退貨OrderSlaveFlowReturnGoodsQty)— 商品出貨後使用者申請退貨
  • 補退價差OrderSlaveFlowOrderSpreadsAmount)— 換規格、活動重新計算導致的正負金額調整

這些事件可能只發生一種,也可能先後疊加發生(例如先取消 1 件、出貨後又退貨 1 件)。但資料庫裡的 QtyTotalPayment 存的是「最初下單當下」的原始值,不會自動反映這些後續異動

所以「先算剩多少」的意思是:拿原始下單的 Qty / TotalPayment,把已經發生過的取消、退貨、補退全部扣除或加回,還原出「現在這個當下,這筆訂單真正還剩下幾件商品、值多少錢」。這一步做完,後面才有正確的基礎去判斷「該退多少錢」——如果沒有先做這一步,數量和金額都會是錯的(沒扣掉之前已經取消/退過的部分)。

案例資訊

訂單編號:MG260204P00004 TS Id:16434

此案例用來驗證「部分取消 + 部分退貨 + 補退款」同時存在時,實際數量與已付金額的重算結果是否正確。

02

運費規則 ShopShippingType_FeeTypeDef

用在:訂單明細頁「運費退款資訊」區塊,決定要不要顯示 / 要不要連動處理運費

訂單子單(orderSlave)上帶有 ShopShippingTypeId,透過此 Id 可對應到商店設定的 ShopShippingType_FeeTypeDef,用來標示這筆訂單當初適用的運費計算規則。退貨/退費流程在判斷「運費是否需要一併退還」、「是否因退貨導致低於免運門檻」時,會需要參考這個欄位。

說明
Fixed固定運費:不論金額或重量,收取固定運費
Free免運費:全店/該商品一律免運
OverPrice滿額免運:訂單金額達到門檻即免運,退貨後若金額低於門檻需另行處理運費補收/退還
PriceRule多階運費:依訂單金額區間套用不同運費
WeightBilling重量計費:依商品總重量計算運費
⚠️

運費是掛在「整張訂單」身上,不是每筆退貨商品各自帶運費。一張訂單通常有很多筆 TS(每個商品一筆),但運費只收一次,會「依附」在其中某一筆特定的 TS 上(不保證一定是第一筆)。所以要判斷運費退款狀態時,得先用訂單主單編號(tmCode)呼叫 csp_GetSalesOrderFeeTradesOrderSlaveCode,才能查出「這張訂單的運費到底掛在哪一筆 TS」;找到那筆 TS 後,再去確認它有沒有一張 RefundRequest_SourceDef == SalesOrderFee 的退款單,藉此決定畫面要不要顯示運費退款資訊。

運費退款判斷流程(4 步驟)

1
觸發退貨 / 取消

使用者對某張訂單(TM,例如 MG260204P00004)申請退貨或取消。

2
拿「訂單」找「運費掛在哪」

用訂單主單編號呼叫 csp_GetSalesOrderFeeTradesOrderSlaveCode,回傳「運費實際掛在哪一筆 TS」。

3
查那筆 TS 有沒有運費退款單

用上一步找到的 TS Code,查 RefundRequest 表是否存在 SourceDef == SalesOrderFee 的退款單。

4
決定畫面顯示

有 → 顯示「運費退款資訊」區塊;沒有 → 不顯示。

03

核心公式 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; //// 補退金額

欄位定義

欄位意義對「實際金額」的影響
QtyTS 原始購買數量(DB 原值)計算基準
OrderSlaveFlowCancelQty已取消的數量正數(相減)
OrderSlaveFlowReturnGoodsQty已退貨的數量正數(相減)
TotalPaymentTS 原始應付金額(DB 原值)計算基準
OrderSlaveFlowCancelTotalPayment取消對應金額讓「實際」變少(扣除已取消部分)
OrderSlaveFlowReturnGoodsTotalPayment退貨對應金額讓「實際」變少(扣除已退貨部分)
OrderSlaveFlowOrderSpreadsAmount補退款價差(改規格/活動重算)補收為正 → 讓「實際」變多;退款為負 → 讓「實際」變少(唯一雙向的變數)
⚠️

這裡是最容易搞混、也最需要注意的地方:「相減」之後結果是變少還是變多,完全取決於 CancelTotalPayment / ReturnGoodsTotalPayment 這兩個欄位本身在資料庫裡存的是正數還是負數——而這是由另一支負責「產生取消 / 退貨事件」的程式(通常在訂單處理服務那端)寫入的,不在這支重算程式裡,無法只看這行公式就完全確定。

但依照「取消 / 退貨後,金額理論上應該要變少」這個業務常理反推:這兩個欄位正常情況下應該以正數(代表取消/退貨對應的金額)代入運算,這樣公式才會是「真的在扣錢」,邏輯才會跟 Qty 公式(Qty − CancelQty − ReturnGoodsQty,同樣是正數相減)一致。

計算後的 Qty / TotalPayment 即為「目前這筆 TS 實際生效(未取消、未退貨)的數量與已付金額」,會顯示在會員訂單明細頁,也作為後續退款金額計算的基礎。

範例試算(純取消 + 退貨,無補退價差)

原始 Qty = 3TotalPayment = 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 還要多

這就是為什麼補退價差是「唯一可能讓實際金額超過『扣除取消退貨後』數字」的變數——因為它代表另一筆獨立的加價或退款事件,跟單純的取消/退貨方向不一定相同。

04

退款判斷 PartialRefundAmount / TotalRefundAmount

用在:會員「訂單明細頁」顯示退款金額欄位

這個章節算出來的數字,就是使用者在「會員中心 → 訂單明細」頁面上看到的退款金額欄位。因為退貨從「使用者申請」到「退款單正式產生」中間有一段流程時間差,畫面不可能讓使用者等到流程跑完才顯示金額,所以系統依「退款單是否已經正式產生」分成兩種情境,決定當下要顯示哪一個數字:

🕒 情境一:申請後、退款單還沒產生

後端還在跑審核/驗退/ERP 產單流程,資料庫還沒有正式的 RefundRequest。畫面改用 PartialRefundAmount(估算值),先讓使用者知道大概能拿回多少錢。

✅ 情境二:退款單已正式產生

系統已經有真正的退款紀錄(甚至可能已退款完成)。畫面改用 TotalRefundAmount(真實值),直接取退款單上的實際金額,比估算值更準確。

🔄 顯示邏輯

同一個「退款金額」欄位,會依當下有沒有退款單,自動在這兩個數字之間切換,對使用者來說永遠只看到一個「目前最準確」的金額。

除了重算 Qty / TotalPayment,訂單明細還需要判斷「這筆 TS 是否存在部分退款」,並算出對應金額顯示給使用者。判斷依據有兩個旗標:

IsExistsOrderSpreadAmount IsExistsPartialQtyCancel 計算 PartialRefundAmount
//// 是否存在補退價差(負數代表需要退款)
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 並決定是否顯示運費退款區塊。

05

前置檢查 是否允許部分退貨

用在:使用者「申請退貨」頁面勾選商品時的前置檢查

在計算退費金額之前,ReturnGoodsRequestService 會先確認商店是否開放「部分退貨」,此設定即文件中提到的 detail.IsPartialReturnGoodsEnabled

1
讀取商店設定

ArrangeShopPartialSetting 將後台設定的 shopPartialSetting.IsPartialReturnGoodsEnabled 帶入 detail.IsPartialReturnGoodsEnabled

2
若商店本身開放部分退貨

ValidatePartialReturn 直接 return,不再檢查活動/加價購限制,允許使用者自由勾選要退貨的商品子單。

3
商店未開放時的例外規則

若訂單含活動(HasActivity)或加價購(HasPurchaseExtra),且非所有商品都允許部分退(HasPartialReturn),則必須連同「不可部分退」的必退商品一起勾選,否則丟出例外「不允許部分退貨」。

4
未攤提活動檢查

對於未攤提到所有商品的活動(例如第 N 件折扣),會額外呼叫 CheckTradesOrderSlaveWithUnAllocatePromotionEngine 驗證勾選組合是否合法。

🚫

禮物卡另有獨立限制:confirmEntity.IsPartialReturnBlockedByGiftCard() 為 true 時,直接禁止部分退貨,優先於其他所有判斷。

🤔 商業原因:商家為什麼會不允許部分退貨?

這些檢查不是技術上做不到,而是背後有實際的商業/會計考量:

🎁 促銷活動資格

「滿 3 件 8 折」「買 2 送 1」這類優惠是依「整組」計算的。只退掉其中 1 件,剩下商品就不再符合門檻,商家得重新拆算折扣、追回多給的優惠,計算複雜又易出錯,乾脆規定要退就整組一起退。

🛍️ 加價購綁定主商品

加價購商品是「因為買了主商品才用特價買到」。主商品退了、加價購商品卻留著,等於使用者用特價買到一個不該單獨享優惠的商品,所以主商品退貨時必須連同加價購一起退。

📦 組合包 / 套裝定價

套裝商品是用「整組」價格販售的,拆開單獨退貨會讓剩下商品的「單價」失去依據,退款金額該算多少容易產生爭議。

🎟️ 禮物卡法規 / 防詐考量

多數地區法規對禮物卡退換較嚴格(通常不可退現金),加上部分退貨可能被利用來反覆套現,商家索性直接禁止禮物卡部分退貨。

⚙️ 商家系統 / 營運能力

部分退貨需要額外的驗貨、拆單、重開發票等作業,系統或人力有限的商店,寧可用「全部退或都不退」的簡單政策降低出錯與客服成本——這就是最上層開關 IsPartialReturnGoodsEnabled 的由來。

💡 一句話總結

只要退貨會打亂「已核算好的優惠、套裝定價、法規限制」,或增加商家作業成本,商家就傾向禁止部分退貨、要求整筆一起處理。

06

資料鏈 四張表的關係

用在:串起「訂單商品」→「退貨申請」→「ERP 退貨處理」→「退款」的整條資料鏈

前面幾個章節提到的所有欄位(OrderSlaveFlowCancelQtyOrderSlaveFlowReturnGoodsTotalPayment…),其實都是同一張「流程表」上的欄位。這張表叫 OrderSlaveFlow,它才是真正把 TradesOrderSlaveReturnGoodsRequestReturnGoodsOrderSlaveRefundRequest 這四張表串在一起的「樞紐(Hub)」。以下依實際程式碼逐一拆解。

資料表白話角色所在資料庫
TradesOrderSlave(TS)訂單裡的「一筆商品子單」,退費計算的最小單位WebStore DB
OrderSlaveFlow每筆 TS 專屬的「狀態與流程紀錄表」,本身沒有業務金額,但存了所有指向其他三張表的關聯 IdWebStore 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; }
💡

重點:OrderSlaveFlowTradesOrderSlave1 對 1(透過 OrderSlaveFlow_TradesOrderSlaveId),但它同時「認識」ReturnGoodsRequestReturnGoodsOrderSlave 各自的 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 就不能再取消了
1
使用者按下「申請退貨」

新增一筆 ReturnGoodsRequest(WebStore DB),記錄退貨原因、數量、退款帳戶等使用者填的資訊。

2
回填 OrderSlaveFlow

把新增的 ReturnGoodsRequest_Id 寫回這筆 TS 對應的 OrderSlaveFlow,狀態設為「待轉單(WaitingToTrans)」,同時把 CanCancel 等操作旗標關閉,避免使用者同時申請取消。

3
轉單至 ERP

背景排程/服務把「待轉單」的申請轉進 ERP,ERP 端建立正式的 ReturnGoodsOrderSlave(實際驗退、供應商處理的明細),並把它的 Id 回填到同一筆 OrderSlaveFlow_ReturnGoodsOrderSlaveId

4
ERP 驗退完成 → 產生退款單

ERP 確認退貨完成後,建立一筆 RefundRequest,用 RefundRequest_TradesOrderSlaveCode 對應回同一筆 TS,並將 RefundRequest_SourceDef 標記為 ReturnGoodsOrderSlave,代表「這筆退款是因為退貨事件產生的」。

5
前台顯示真實退款金額

訂單明細頁偵測到這筆 TS 已經有 RefundRequest,就改用第 4 章介紹的 TotalRefundAmount(退款單真實金額)取代原本的估算值 PartialRefundAmount

RefundRequest 的來源類型(RefundRequestSourceDefEnum

退款單不是只因為「退貨」才會產生,程式碼裡定義了 5 種來源,退貨只是其中之一:

SourceDef意義
CancelOrderSlave取消子單產生的退款
ReturnGoodsOrderSlave退貨子單產生的退款(本章討論的主角)
SalesOrderFee運費退款(見第 2 章)
SalesOrderSlave逾期未領退款
RechargeReceipt訂單差價調整退款

一張圖看懂整條鏈

TradesOrderSlave
(商品子單)
OrderSlaveFlow
(樞紐,1:1 對應 TS)
ReturnGoodsRequest
(使用者申請)
ReturnGoodsOrderSlave
(ERP 驗退明細)
RefundRequest
(實際退款單)

一句話總結:TradesOrderSlave 是「退什麼商品」,OrderSlaveFlow 是「串起一切的樞紐」,ReturnGoodsRequest 是「使用者說要退」,ReturnGoodsOrderSlave 是「ERP 實際處理退貨」,RefundRequest 是「錢真的退了多少」——四張表依序記錄同一件退貨事件在不同階段、不同系統裡的樣貌。

🔁 補充:同一筆 TG,退貨/取消是怎麼「同步」到 ERP 的?

用在:解釋「為什麼我申請退貨後,訂單頁狀態不是馬上變、而是要等一下」——這一段是 WebStore ↔ ERP 兩個資料庫之間,真正負責「搬資料」的排程機制。

💡

關鍵認知:同步的最小單位不是「這一筆退貨」,而是「整個 TG(訂單群組)裡所有狀態為待轉單的 TS」。排程會一次掃描整個資料庫裡所有「待轉單」的 CancelRequestReturnGoodsRequest,不是只針對你剛剛送出的那一筆。

1
使用者送出退貨/取消申請

WebStore DB 寫入 ReturnGoodsRequest(或 CancelRequest),並把對應的 OrderSlaveFlow 狀態改為 WaitingToTrans(待轉單)。這一步只發生在 WebStore DB,ERP 還完全不知道這件事。

2
排程 Job 定期掃描(NMQV2_ImportWebStoreDBRequestDataToERPDB)

這是一支實際存在的 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] (...)
3
同一支 Job 再掃描「所有待轉單」,逐筆丟給 NMQV2 非同步佇列

Job 接著會查詢 ERPDB 裡所有 ReturnGoodsRequest_StatusDef = 'WaitingToTrans'(取消單同理)的資料——注意這裡查的是「全庫」,不是限定某個 TG——把每一筆的 ReturnGoodsRequest_Id 個別包成一個 Task,塞進 NMQV2DB.dbo.Task 表,指定要交給 ReturnGoodsOrderBatchInsert(退貨)或 CancelOrderProcess(取消)這兩支背景 Worker 處理。

4
NMQV2 Worker 消化 Task,正式在 ERP 產生 ReturnGoodsOrderSlave 與 RefundRequest

這一步的實作是獨立的 ERP 端 Worker 服務(不在本 mweb repo 內,屬於 ERP 系統程式碼),依 Task 內容把退貨單轉成正式的驗退明細 ReturnGoodsOrderSlave,並視付款方式產生 RefundRequestRefundRequest_SourceDef = ReturnGoodsOrderSlave)。

5
ERP 處理完成後,狀態同步「寫回」WebStoreDB

透過 csp_SyncERPDBReturnGoodsRequestStatusToWebStoreDB,把 ERPDB 端 ReturnGoodsRequest 最新的狀態(StatusDefIsClosed 等)透過 Linked Server 寫回 WebStoreDB 同一張表的同一筆資料,前台訂單頁下次查詢時就能看到最新狀態。

階段發生在哪裡關鍵物件顆粒度
使用者申請WebStoreDBReturnGoodsRequest / CancelRequest單筆 TS
匯入 ERPDBWebStoreDB → ERPDBcsp_ImportWebStoreDBRequestDataToERPDB整批(所有待轉單資料,非單一 TG)
丟入非同步佇列ERPDB → NMQV2DBTask(Job: ReturnGoodsOrderBatchInsert / CancelOrderProcess)每筆 ReturnGoodsRequest_Id / CancelRequest_Id 各自一個 Task
ERP 實際處理ERP Worker(ERP 系統程式碼)ReturnGoodsOrderSlave / RefundRequest單筆 Task
狀態寫回ERPDB → WebStoreDBcsp_SyncERPDBReturnGoodsRequestStatusToWebStoreDB單筆 ReturnGoodsRequest_Id
⚠️

對之前「TG 觸發轉單」說法的修正:先前提到「轉單」是以整個 TG 為單位觸發,這在金流付款成功轉單TransToERPTmpV2ProcessTransferOrderProcessor)的情境下正確——那是以 tradesOrderGroupId 當參數觸發整個訂單的轉單。但退貨/取消走的是另一支排程(csp_ImportWebStoreDBRequestDataToERPDB),它是定期全庫掃描「待轉單」狀態的資料,並非以某個 TG 為觸發單位;只是實務上同一個 TG 底下的多筆退貨/取消申請,很可能剛好在同一次排程執行時一起被撈到、一起轉單,感覺上像是「整批」處理。

🧮 補充:3 筆 TS+固定運費,退貨後 ReturnGoodsOrderSlave 會是幾筆?

用在:釐清「商品退貨」與「運費退款」在資料庫裡其實是兩條平行的軌道,避免誤以為運費也會擠進 ReturnGoodsOrderSlave 多出一筆。

結論:只會是 3 筆,不會是 4 筆。運費退款完全不經過 ReturnGoodsOrderSlave,它走的是另一張獨立的表:SalesOrderFee + 對應的 RefundRequest

原因可以從實際的資料表設計與 ERP 端 SP 邏輯直接對照出來:

商品退貨運費退款
負責的主表ReturnGoodsOrderSlaveSalesOrderFee
對應顆粒度1 筆退貨的 TS = 1 筆1 個 TG(訂單群組)通常只有 1 筆運費資料,不管底下有幾個 TS
關鍵欄位ReturnGoodsOrderSlave_TradesOrderSlaveId(指向單一 TS)SalesOrderFee_TradesOrderSlaveId(運費實際掛載的那個 TS,可能不是第一筆)
退款時寫入ERP Worker 處理 Task 後產生 RefundRequest_SourceDef = ReturnGoodsOrderSlavecsp_ReturnSalesOrderFee 直接產生 RefundRequest_SourceDef = SalesOrderFee
3 個 TS 全部退貨 ReturnGoodsOrderSlave × 3
(純商品明細,無運費欄位)
同一張訂單的運費(若符合退運費規則) RefundRequest × 1
SourceDef = SalesOrderFee

也就是說,一次「全部退貨」的動作,實際上同時觸發兩條互不相干的資料軌道

  • 商品軌道:3 筆 ReturnGoodsOrderSlave,一個 TS 一筆,欄位只描述商品本身(QtyPriceTotalPaymentTotalDiscount),完全沒有運費相關欄位。
  • 運費軌道:運費有自己的主表 SalesOrderFee,退運費是由 ERP 端 csp_ReturnSalesOrderFee 這支 SP 獨立處理,直接寫入一筆 RefundRequestSourceDef = 'SalesOrderFee'SourceId 指向 SalesOrderFee_Id),完全不會去新增或修改 ReturnGoodsOrderSlave
💡

這也呼應前面「運費類型定義」章節提到的:查詢運費退款狀態要用 csp_GetSalesOrderFeeTradesOrderSlaveCode 找出運費掛在哪個 TS、再去查 RefundRequest,而不是去看 ReturnGoodsOrderSlave——因為運費本來就不會出現在那張表裡。

⚠️ 補充修正:消費者按下「退貨」,運費會自動一起退嗎?

用在:釐清「運費退款」的觸發者是誰——避免誤以為運費退款是退貨當下由系統自動算好、自動觸發的。

⚠️

結論:不會。消費者在 mweb 申請退貨,預設只會走商品的 ReturnGoodsOrderSlave 流程,運費不會被自動一併退還。運費退款單(RefundRequest_SourceDef = SalesOrderFee)需要由客服/營運人員在 ERP 後台(SMS)另外手動建立,只有極少數的「整筆訂單全部取消」情境有系統自動化例外。

具體證據如下:

1. 建立運費退款單的 SP(csp_ReturnSalesOrderFee)在 mweb repo 裡完全找不到呼叫入口

這支 SP 帶有 @userName(操作人員)參數,且註解寫著「放寬客戶新增運費退款單權限」——代表它是由客服/營運人員在 ERP 後台(SMS)手動操作時才會被呼叫,並不是消費者退貨當下由 mweb 系統自動觸發的。

2. mweb 端能找到的運費退款相關程式碼,都只是「查詢/更新已存在的運費退款單」

RefundRequestService.cs 裡與運費相關的邏輯,都是在「已經有一筆運費退款單存在」的前提下,去查詢帳戶資料或更新退款狀態——沒有任何「建立」運費退款單的邏輯。

3. 系統唯一會自動退運費的情境,是「取消」(Cancel)的特例,且限定「整個 TM 底下的 TS 全部取消」

只有在 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_BatchGeneratePaymentRequestcsp_GenerateCreditCardRefundRequestSalesOrderFee
其他一般取消/部分取消情境❌ 不會同退貨,需人工於後台建立
07

總結:一次退費計算的完整脈絡

  • 先確認商店 ShopShippingType_FeeTypeDef 運費規則,評估退貨是否影響免運門檻/運費金額。
  • IsPartialReturnGoodsEnabled 與活動/加價購/禮物卡限制,判斷本次是否允許「部分」退貨。
  • 透過 ArrangePartialQtyCancelInfo 用「原始 Qty / TotalPayment」扣除「取消、退貨」再加回「補退價差」,得到目前實際生效的數量與金額。
  • 再依 OrderSlaveFlowOrderSpreadsAmountIsPartialQtyCancel 判斷是否存在部分退款,估算 PartialRefundAmount;若已有正式退款單,改以 TotalRefundAmount(退款單金額加總)呈現。
  • 運費退款獨立處理,透過 SalesOrderFee 類型退款單與 TS 對應碼另行結算。
  • 使用者申請退貨/取消後,經由 OrderSlaveFlow 樞紐串起 ReturnGoodsRequestReturnGoodsOrderSlaveRefundRequest 四張表,並透過排程 csp_ImportWebStoreDBRequestDataToERPDB(全庫掃描待轉單資料,非單一 TG)同步進 ERP,再由 NMQV2 背景 Worker 產生正式退貨明細與退款單。
案例 MG260204P00004 / TS 16434

此訂單用來驗證:當同一筆 TS 同時存在部分取消數量與退貨數量時,QtyTotalPayment 是否能正確反映「扣除取消與退貨後、且加回補退價差」的最終結果,並確保 UI 顯示的部分退款金額與後續實際退款單金額一致。

整理自 case1-退費計算.mdMemberTradesOrderService / ReturnGoodsRequestService 原始碼