佔額
訂單符合回饋活動後,系統先向 PCPS 保留一筆額度。線上訂單要等付款結果,才能決定繼續給獎或返還;線下訂單則在回饋流程中完成佔額
「佔額」是回饋活動在 PromoCodePool Service(PCPS) 的 Group 中,先替符合條件的訂單會員分派並兌換一筆 Code,使該筆回饋占用活動限額。這是配額操作,不是給點或給券。Promotion Worker 另外在 DynamoDB(下稱 DDB)存回饋主單及子單狀態,藉此決定後續是否能回饋、取消及稽核
本專案支援三種相關活動類型:RewardReachPriceWithPoint2、RewardReachPriceWithRatePoint2、RewardReachPriceWithCoupon,分派器、Job 的 IsQuotaPromotion 使用由 GroupCode 判斷
OrderPlaced:建立新的佔額 NMQ Task
下單後,誰真正向 PCPS 佔額?
flowchart TB placed["EDA OrderPlaced"] --> dispatcher["佔額分派器"] dispatcher --> filter["找有 GroupCode 的活動"] filter -->|"找到佔額活動"| create["每檔活動建立一筆 NMQ Task"] filter -->|"沒有佔額活動"| stop["結束分派"] create --> job["點數或券 Job"] job --> calculate["取得規則並試算訂單"] calculate -->|"中活動"| redeem["PCPS dispatch-and-redeem"] calculate -->|"未中活動"| unmatch["DDB Unmatch"] redeem -->|"佔額成功"| occupy["DDB Occupy"] redeem -->|"額度不足或未回 Redeemed"| noquota["DDB MatchWithoutQuota"]
IsMatch 與佔額成功是兩個判斷。OrderFailed:查出已佔額的 Code 並返還
付款失敗後,依 DDB 狀態決定返還
flowchart TB failed["EDA OrderFailed"] --> dispatcher["佔額分派器"] dispatcher --> record["依活動與訂單查 DDB 主單"] record -->|"狀態為 Occupy"| find["PCPS get-by-label 查 CodeId"] find --> reverse["PCPS reverse-redeemed-and-dispatched"] reverse --> cancel["DDB 主單與明細改為 Cancel"] record -->|"Unmatch、MatchWithoutQuota、Cancel"| skip["略過返還"] record -->|"查無紀錄"| error record -->|"其他狀態"| error["拋出異常"]
OrderFailed 在分派器內直接處理返還。查不到紀錄或遇到未預期狀態會報錯。OrderFailed 在同一個 Dispatcher 內直接走返還流程,不會建立上述佔額 NMQ Task。只有 DDB 主單為 Occupy 才以 GroupCode + TG 查 PCPS 的 CodeId,呼叫 reverse-redeemed-and-dispatched,成功後更新 DDB 為 Cancel;給券活動還會更新取消券數量。這裡的 reverse-redeemed 是返還已佔額度,與 OrderPlaced 後 Job 執行的 dispatch-and-redeem 是相反操作
OrderCreated 的後續回饋分派與 Occupy → WaitToReward 轉換,見「何時佔額:線上與線下」分頁;它不是 PromotionRewardOccupyDispatcherJob 的分支
佔額資料在服務間如何流動
flowchart LR Order["訂單事件"] --> Worker["Promotion Worker"] Worker -->|查詢活動與規則| Promo["Promotion Service / Engine"] Promo -->|回傳規則與 GroupCode| Worker Worker -->|分派、查詢、返還 Code| PCPS["PCPS"] Worker -->|回饋紀錄與狀態| DDB["DDB"] Worker -->|回饋條件成立| Reward["點數中心/券服務"] Audit["稽核 Job"] -->|比對狀態| DDB Audit -->|查佔額筆數| PCPS
| 概念/服務 | 職責 | 關鍵資料 |
|---|---|---|
| Promotion Service / Engine | 查訂單時間有效的活動與活動規則;試算是否符合活動 | PromotionEngineId、活動類型、GroupCode、IsMatch |
| NMQv3 | OrderPlaced 分派器逐活動建立點數/券佔額 Task;OrderCreated 另建回饋 Task;OrderFailed 在佔額分派器內直接處理返還 |
JobName、Data、EventSource |
| PCPS | 真正分派、兌換、查詢與返還配額 Code | GroupId/GroupCode、CodeId、ReceiverId、Labels、State |
| DDB 與 Locker | 保存回饋主/子單及歷程;以訂單+活動鍵鎖住並行處理 | Occupy、WaitToReward、MatchWithoutQuota、Cancel |
| 點數中心/券服務 | 在回饋條件與 BookingTime 成立時實際發點/發券 | Reward、明細、已給/回收數量 |
| 稽核器 | 對照 DDB 回饋狀態和 PCPS 實際佔額筆數 | 期望 0 或 1 筆 |
Group 先建立;訂單再佔用其中一筆 Code
flowchart TB
Activity["回饋活動設定"] --> Create["Web API 建立 Group"]
Create --> Enable["啟用 Group"]
Enable --> Save["保存 GroupCode"]
Save --> Rule["Worker 取得活動規則"]
Rule --> Match{"訂單符合?"}
Match -->|否| End["不佔額"]
Match -->|是| Redeem["PCPS 分派並兌換 Code"]
Redeem --> State{"State = Redeemed?"}
State -->|是| Occupy["記錄佔額成功"]
State -->|否| NoQuota["記錄未成功佔額"]
GroupCode 串起建立與訂單佔額兩階段。PCPS(Promo Code Pool Service)以 Group 保存活動的佔額規則,以 Code 表示一次已分派的額度。Promotion Web API 建立活動時負責建立並啟用 Group;Worker 在訂單符合活動後,才對該 Group 呼叫 dispatch-and-redeem。建立群組得到的 Id 會寫成活動的 PromotionEngine_GroupCode,Worker 取得規則後則可從 Rule-Record 的 GroupCode 找到同一群組
建立/更新 Group 的參數
| PCPS 欄位 | Promotion Web API 傳入值 | 在回饋活動的意義 |
|---|---|---|
ModeType |
Functional |
用多少才產生多少 Code;不是先匯入整批代碼。 |
DispatchType |
Random |
搭配 Functional 時,依代碼產生規則即時產生 Code;此處的 Random 不是「隨機決定哪筆訂單中活動」。 |
Pattern |
GUID |
產生 Code 使用的格式設定。 |
Since |
活動的 StartDateTime |
群組開始時間。 |
Until |
DateTime.MaxValue |
群組不隨活動的 EndDateTime 到期,讓活動結束後補處理舊訂單時仍可使用該群組;活動本身是否有效仍由活動時間與規則判斷。 |
LimitRuleSettings[].ActionType |
Redeem |
限額規則套用在兌換動作。 |
LimitRuleSettings[].LimitTarget |
2 |
PCPS 的會員限額維度;Web API 程式碼明註 2 代表會員。 |
LimitRuleSettings[].LimitRules[].Quota |
活動的 Quota |
上述會員維度的兌換額度設定。Quota <= 0 時,建立群組方法不接受該設定。 |
CanRedeemDirectly |
true |
允許直接兌換;Worker 使用的 dispatch-and-redeem 會在同一次呼叫中分派並兌換 Code。 |
Web API 透過 POST api/groups 建立、PATCH api/groups/{id}/enable 啟用,更新時用 PUT api/groups/{id}
建立 Group 實例。 假設商店 1001 建立一檔回饋活動,活動期間為 2025-05-01 00:00 至 2025-05-31 23:59(台灣時間),設定 Quota=1。Promotion Web API 會依活動資料組成下列 PCPS request。商店與活動設定是示意值;欄位及 Until=DateTime.MaxValue 對應 PromoCodePoolService.BuildQuotaGroupRequestEntity 的實際程式碼。
1 | // POST api/groups;此處以 C# 表示 Web API 組出的 request,非 HTTP 原始 JSON。 |
建立成功後,Web API 取回 PCPS 回應的 Id,再呼叫 PATCH api/groups/{id}/enable。假設回傳 Id=6f114aae-3391-4e0d-84cc-fdb3ed68e87a,活動會把這個字串存成 PromotionEngine_GroupCode,後續訂單就用它指定要佔額的 Group。此 ID 僅為串連範例的示意值,程式碼沒有保證 Group ID 一定採用 GUID 格式。雖然活動在 5 月底結束,Group 的 Until 仍為 DateTime.MaxValue;訂單是否屬於活動期間,仍需按訂單時間與活動規則判斷。
每筆訂單佔額的參數
| 欄位 | Worker 傳入值與用途 |
|---|---|
URL 的 groupId |
活動的 GroupCode,指定要佔用哪個 PCPS Group。 |
ReceiverId |
會員 ID 的字串,指定接收這筆 Code 的會員。 |
Tags |
空集合。Worker request model 說明 Tags 用來形成限額鍵、不能拿來查詢;此流程沒有額外傳入 Tag。 |
Labels |
放訂單彙總碼 TG,作為可查詢註記。後續以 GroupCode + TG 呼叫 get-by-label 找出該訂單的 Code。該欄位的 model 註明長度上限為 180 bytes,且不得包含 @ 或 |。 |
| HTTP headers | ny-market 是市場、ny-shop-id 是商店 ID、ny-source-type 固定為 PromotionReward;Worker 佔額時的 ny-user-id 是會員 ID。Web API 建/改 Group 時的 ny-user-id 則來自操作使用者名稱。 |
線下訂單在查 DDB/PCPS 時使用 CrmSalesOrderCodePrefix + CrmSalesOrderId 作為訂單代碼。PCPS 回傳的單筆 CodeId 用於日後返還;HTTP 成功不等於佔額成功,Worker 還會確認回傳 State=Redeemed
線上訂單佔額實例。 沿用上面的 Group,假設會員 2002 在 2025-05-12 下了一筆線上訂單 TG250512P00001,OrderPlaced 佔額 Job 試算後確認 IsMatch=true。這個 TG 樣態取自專案測試資料;商店、會員及 Group ID 仍是示意值。Worker 會送出:
1 | POST api/groups/6f114aae-3391-4e0d-84cc-fdb3ed68e87a/dispatch-and-redeem |
這裡的 URL 指向活動 Group,receiverId 指向會員,labels 把 Code 與 TG 關聯起來,tags 沒有額外值。若 PCPS 回傳 State=Redeemed,Worker 視為佔額成功,線上 DDB 主單記為 Occupy;之後可用相同 GroupCode 與 Label=TG250512P00001 呼叫 get-by-label 查回該 Code。
線下訂單佔額實例。 同一檔活動若也適用線下訂單,假設另一位會員 2003 的 CrmSalesOrderId=987654321 在活動期間符合回饋條件。線下回饋 Job 會把 CRM 訂單 ID 組成 CrmSalesOrder:987654321,將它當作 PCPS Label;CrmSalesOrder: 是 OthersCrmSalesOrderPromotionRewardCalculateService.CrmSalesOrderCodePrefix 的實際常數值。線下不會先等 OrderPlaced 佔額 Job,而是在回饋 Job 試算命中後送出:
1 | POST api/groups/6f114aae-3391-4e0d-84cc-fdb3ed68e87a/dispatch-and-redeem |
線下查詢時也用該 Group ID 與 Label=CrmSalesOrder:987654321 呼叫 get-by-label。若 PCPS 回傳 State=Redeemed,線下 DDB 主單直接進入 WaitToReward;同一範例使用不同會員,是因為 Group 設定的 Quota=1 套用在會員限額維度。兩種訂單的 request 欄位與成功判斷都對應點數/券 Job 的 ProcessOccupyQuotaAsync
線上與線下如何形成查詢 Label
flowchart TB TG["線上 TG250512P00001"] --> TGL["Label = TG250512P00001"] CRM["線下 CRM 訂單 987654321"] --> CRML["Label = CrmSalesOrder:987654321"] TGL --> Query["GroupCode + Label 查 Code"] CRML --> Query
| 操作 | Worker 呼叫 | 重要語意 |
|---|---|---|
| 佔額 | POST api/groups/{groupId}/dispatch-and-redeem |
成功 HTTP 回應仍需 State=Redeemed 才算佔額成功 |
| 查 TG 佔額 | GET api/codes/get-by-label?GroupId=...&Label=...&Limit=30 |
Labels 是查詢註記;GetMemberPromotionQuotaInfoByTradesOrderGroup 要求恰好一筆 |
| 返還 | POST api/codes/{codeId}/reverse-redeemed-and-dispatched |
以 GroupId、ReceiverId 返還;OrderFailed 使用單筆返還,逆流程 helper 可逐筆返還 |
| 失敗/例外 | 程式處理 | 影響 |
|---|---|---|
| 佔額 HTTP 409 | 轉 InsufficientQuotaException,不重試 |
Job 回 isOccupy=false,符合活動者記 MatchWithoutQuota |
| 佔額網路/其他 HTTP 錯誤 | Client 有有限次重試;仍失敗拋例外 | Job 包為可釋放鎖的失敗;不應解讀成「額度不足」 |
| 佔額成功但寫 DDB 失敗 | PCPS 與 DDB 分開呼叫 | 可能留下 PCPS 已兌換、DDB 缺漏;這是由呼叫順序推得的一致性風險,需靠查 PCPS 與任務日誌確認個案 |
OrderFailed 找不到 DDB/PCPS Code |
DDB 查詢有限次重試;單筆查不到或非恰好一筆會拋錯 | 不會默默把 DDB 寫 Cancel |
逆流程 ReverseOccupyQuotaAsync 查無/未兌換 |
容忍並結束;Timeout/TaskCanceled 最多三次 | 這與 OrderFailed 的嚴格單筆返還路徑不同 |
注意:PromotionRewardOccupyDispatcherJob 有 OrderFailedDelaySeconds=30 常數與「增加等待時間」註解,但目前 ProcessQuotaReverseRedeemed 呼叫前**沒有使用該常數或 Task.Delay**。該路徑以 DDB 讀取/鎖的重試應對先後順序,不宜宣稱實際固定等待 30 秒
活動與 PCPS Group 使用兩條時間界線
flowchart LR
Order["訂單時間"] --> Activity{"落在活動期間?"}
Activity -->|否| NoMatch["不符合活動"]
Activity -->|是| Rules["試算活動條件"]
Rules -->|符合| Group{"處理時間已達 Group Since?"}
Rules -->|未符合| NoMatch
Group -->|是| Redeem["可嘗試 PCPS 佔額"]
Group -->|否| Block["不能提前佔額"]
Since/Until。圖中的「可嘗試」仍須有額度且 PCPS 回傳成功。回饋活動的有效期和 PCPS 佔額群組的有效期是兩組設定。活動規則的 Since、Until 分別採用活動的 StartDateTime、EndDateTime,用來判斷訂單在下單當時是否符合活動時間。建立 PCPS 群組時,Since 同樣採用活動開始時間,但 Until 固定為 DateTime.MaxValue,沒有跟著活動結束時間到期。因此,活動已結束後,只要補跑流程仍能找到該活動與訂單,過去符合活動期間的訂單仍有機會在現在向 PCPS 佔額
| 情境 | 活動時間判斷 | PCPS 群組時間判斷 | 結果 |
|---|---|---|---|
| 現在補跑活動期間內的舊訂單 | 訂單時間落在活動開始與結束之間 | 群組的 Until 尚未到期 |
時間設定不會阻止佔額,仍須符合活動條件且有額度 |
| 現在處理活動開始前的訂單 | 訂單時間早於活動開始 | 即使群組已存在 | 訂單不符合活動時間,不應進入該活動佔額 |
| 現在提前處理未來活動的訂單 | 訂單時間可能落在未來活動期間 | 處理當下早於群組 Since |
依群組設定不應提前佔額;PCPS 的實際拒絕行為仍須以 PCPS 實作確認 |
補跑的界線:Group 不隨活動結束而過期,讓舊訂單仍有機會佔額;一般 Worker 的候選活動查詢範圍仍會影響能否補跑。
建立活動時,backend API 只對支援佔額的回饋類型且 Quota > 0 的活動建立並啟用 PCPS 群組,取得 GroupId 後寫入 PromotionEngine_GroupCode。Quota = 0 代表不建立佔額群組。活動開始後,更新流程會直接跳過佔額設定變更
這個時間設定解決的是 PCPS 群組過期問題,不保證任意久以前的活動都能由一般 Worker 分派器找出。目前該分派器的候選活動查詢從現在往前七天開始,再用訂單時間過濾;超過查詢範圍的補跑仍需檢查實際入口
佔額更新
活動尚未開始且有佔額設定時,backend API 可以更新群組的額度與開始時間;活動開始後,佔額設定更新流程會直接返回。
資料表
PromotionEngine_GroupCode 保存 PCPS 回傳的 Group ID,供 Worker 從活動規則取得後佔額。
線上:下單先佔額,付款後才進入待回饋
sequenceDiagram
participant E as 訂單事件
participant D as 分派器
participant N as NMQ Task
participant J as 點數/券 Job
participant P as PCPS
participant R as DDB
E->>D: OrderPlaced
D->>N: 建立佔額 Task
N->>J: 執行佔額 Job
J->>J: 取得規則並試算
opt 符合活動且有 GroupCode
J->>P: dispatch-and-redeem
P-->>J: State
J->>R: 記錄佔額結果
end
E->>D: OrderCreated
D->>N: 建立延後回饋 Task
N->>J: 執行回饋 Job
J->>R: 讀取既有佔額紀錄
opt 狀態為 Occupy
J->>R: 轉 WaitToReward
end
線上訂單(ECom)
OrderPlaced到達佔額分派器。它按訂單時間選出有效的三類回饋活動,保留有GroupCode的活動;點數活動還需ExtendInfo有值。每檔活動各建一個點數 V2 或券 Job,要求的EventSource=OrderPlaced。分派本身尚未扣額度- Job 取得同一筆
PromotionRuleRecord,做通路檢查和回饋試算。在 DDB key 上取鎖;沒有既有紀錄、IsMatch=true才向 PCPS 呼叫dispatch-and-redeem。回傳State=Redeemed才當作成功;配額不足則記為失敗。之後寫入 DDB 主/子單。既有紀錄則直接返回,以 DDB 作為重跑的防護之一 OrderCreated進入另一個分派器。對有GroupCode的活動,它把回饋 Job 的BookingTime設成UtcNow + 5 分 10 秒,試圖讓佔額流程先完成。回饋 Job 若查不到線上佔額活動的 DDB 紀錄,會拋錯而不直接給點/券;查到Occupy才建立績效 Job 並轉WaitToReward。到真正回饋時間前不執行給點/券
線下訂單(Others)
線下回饋 Job 若尚無 DDB 紀錄,會直接試算;符合活動且有 GroupCode 時在同一次回饋流程呼叫 PCPS 佔額,再插入 DDB。成功的線下紀錄初始即為 WaitToReward,不會經過線上訂單專用的 Occupy 暫存狀態
線下:佔額與待回饋在同一個 Job
flowchart TB
Offline["線下回饋 Job"] --> Record{"已有 DDB 紀錄?"}
Record -->|是| Existing["依既有狀態處理"]
Record -->|否| Calculate["試算是否符合活動"]
Calculate -->|未符合| Unmatch["DDB Unmatch"]
Calculate -->|符合且有 GroupCode| PCPS["PCPS 佔額"]
Calculate -->|符合且無 GroupCode| Normal["一般待回饋流程"]
PCPS -->|成功| Wait["DDB WaitToReward"]
PCPS -->|未成功| NoQuota["DDB MatchWithoutQuota"]
OrderPlaced 暫存階段;已有 DDB 紀錄時不沿著「首次試算」路徑重建佔額。績效的兩個時點:回饋 Job 先建立 PromotionRewardLoyaltyPointsPerformanceV2 NMQ Task;該績效 Job 後續才非同步寫入 PromotionEnginePerformance。Task 已建立不表示資料表已寫入。
線上與線下何時建立績效 Task
flowchart TB
Online["線上 OrderPlaced"] --> O["DDB Occupy"]
O --> Created{"後續進入哪條路徑?"}
Created -->|OrderCreated 讀到 Occupy| OnlineTask["建立績效 Task"]
Created -->|付款前取消| Skip["不建績效 Task"]
OnlineTask --> Wait["DDB WaitToReward"]
Offline["線下回饋 Job"] --> Match{"符合且佔額成功?"}
Match -->|是| OfflineTask["建立績效 Task"]
Match -->|否| Skip
OfflineTask --> OfflineWait["DDB WaitToReward"]
OnlineTask --> PJob["績效 Job 執行"]
OfflineTask --> PJob
PJob --> Report["寫入績效表"]
OrderCreated 回饋流程讀到 Occupy 時建立 Task;線下在同一次回饋 Job 試算並佔額成功後建立 Task。寫表由另一個 Job 完成。| 訂單來源 | 佔額成功後 | 建立績效 Task 的時機 |
|---|---|---|
| 線上 | OrderPlaced 記為 Occupy |
後續 OrderCreated 回饋 Job 讀到 Occupy,建立 Task 並轉成 WaitToReward。 |
| 線下 | 同一回饋 Job 試算、佔額,成功後進入 WaitToReward |
同一回饋 Job 建立績效 Task。 |
建立績效 Task 不必等點數或券實際發出。若線上訂單在付款前從 Occupy 取消,正常流程不建立績效紀錄;若已轉為 WaitToReward 後才取消,績效稽核仍會檢查其紀錄。
中活動與佔額是兩層判斷
flowchart TB
Rule{"有 GroupCode?"} -->|否| Normal["一般回饋流程"]
Rule -->|是| Match{"試算中活動?"}
Match -->|否| Unmatch["DDB Unmatch"]
Match -->|是| PCPS["呼叫 PCPS 佔額"]
PCPS --> Result{"State = Redeemed?"}
Result -->|否| NoQuota["DDB MatchWithoutQuota"]
Result -->|是,線上| Occupy["DDB Occupy"]
Result -->|是,線下| Wait["DDB WaitToReward"]
| 條件 | PCPS 動作 | DDB 主單結果 | 後續 |
|---|---|---|---|
沒有 GroupCode |
無 | 中活動為 WaitToReward,未中為 Unmatch |
一般回饋路徑 |
有 GroupCode,試算未中 |
無 | Unmatch |
不給點/券 |
有 GroupCode,試算中,PCPS 回 Redeemed,線上 OrderPlaced |
分派並兌換 1 Code | Occupy |
等 OrderCreated 轉狀態 |
有 GroupCode,試算中,PCPS 回 Redeemed,線下 |
分派並兌換 1 Code | WaitToReward |
按 BookingTime 回饋 |
有 GroupCode,試算中,額度不足或回傳狀態非 Redeemed |
佔額未確認成功 | MatchWithoutQuota |
不進入回饋 |
| 線上回饋 Job 無 DDB 紀錄 | 無 | 不建立 | 拋錯,待前段佔額資料釐清 |
線上回饋 Job 讀到 Occupy |
無新佔額 | Occupy → WaitToReward |
建績效 Job,後續給點/券 |
OrderFailed 且 DDB 為 Occupy |
查 Code 並返還 | Occupy → Cancel |
券另增加取消數量 |
DDB 主單狀態如何轉換
stateDiagram-v2 [*] --> Unmatch: 試算未中 [*] --> MatchWithoutQuota: 中活動但 PCPS 未成功 [*] --> Occupy: 線上 OrderPlaced 佔額成功 [*] --> WaitToReward: 線下佔額成功/非佔額活動中獎 Occupy --> WaitToReward: OrderCreated 回饋 Job Occupy --> Cancel: OrderFailed 返還 PCPS 成功 WaitToReward --> Reward: 達 BookingTime 且回饋成功 WaitToReward --> Cancel: 取消/逆流程重算後不符條件 Reward --> Cancel: 全部回收或相關取消流程
Occupy 是線上付款前的暫存狀態;線下佔額成功直接進入 WaitToReward。Reward 等狀態的後續逆流程有多種分支;上圖呈現與佔額直接相關的主要轉換,不代表每種回收情境都必定直接寫 Cancel。點數與券插入紀錄的狀態判斷一致
為什麼要有獨立的 Occupy 狀態?
線上訂單收到 OrderPlaced 時,訂單已成立但尚未付款。此時先替符合活動條件的訂單保留額度,並把 DDB 主單記為 Occupy。它表達的是「PCPS 已成功佔額,付款結果仍待確認」,因此不會把這筆紀錄直接當成可以給點或給券的 WaitToReward。程式中的 RewardStatusEnum.Occupy 也明確註明這是訂單成立但未付款的暫時狀態
獨立狀態讓後續流程有清楚的分流依據
- 付款完成後才進入待回饋。 回饋 Job 讀到
Occupy,才將主單改為WaitToReward,再依回饋時間執行給點或給券。佔額成功本身不等於獎勵已可發放 - 付款失敗時能精準返還。
OrderFailed只對Occupy執行 PCPS 返還並更新為Cancel;Unmatch、MatchWithoutQuota、Cancel都不需要返還。這避免把「有中活動但沒佔到額度」誤當成已佔額 - 能辨認卡住的中間階段。 稽核會檢查
Occupy是否仍對應一筆 PCPS 佔額,也會指出訂單已非待付款、DDB 卻仍停在Occupy的情況,方便追查後續回饋 Job 是否漏執行
OrderFailed 的返還順序
sequenceDiagram
participant E as OrderFailed
participant D as 佔額分派器
participant R as DDB
participant P as PCPS
E->>D: 付款失敗事件
D->>R: 查活動與訂單紀錄
R-->>D: RewardStatus
alt 狀態為 Occupy
D->>P: GroupCode + TG 查 CodeId
P-->>D: CodeId
D->>P: reverse-redeemed-and-dispatched
P-->>D: 返還結果
D->>R: 主單與子單轉 Cancel
else Unmatch、MatchWithoutQuota、Cancel
D->>D: 略過返還
else 查無紀錄或其他狀態
D->>D: 拋出異常
end
OrderFailed 在分派器內直接返還;一般取消、退款與重算有另一條逆流程。OrderFailed 走佔額分派器的另一分支:以訂單時間再找有 GroupCode 的活動,排除純線下活動,依活動類型取得點數/券 DDB 紀錄。只有 RewardStatus=Occupy 才返還;Unmatch、MatchWithoutQuota、Cancel 略過,其餘狀態當異常。返還先從 PCPS 以 TG 查到單筆 Code,再呼叫 Reverse;成功後把主/子單改 Cancel,券另外更新道具表 CanceledCouponQty
訂單後續取消、退款或重算也可能返還已佔額資源。點數流程在重算後不符合活動時呼叫 ReverseOccupyQuotaAsync;券流程在整單取消,或重算應給券 0 且尚未給券時呼叫它。該 helper 以 TG 查可能多筆 Code,逐筆 Reverse,查無/未兌換視為可容忍,逾時有限次重試。返還時機不限付款失敗
取消、退款或重算後不再符合活動,依點數或券的規則判定是否應返還。
ReverseOccupyQuotaAsync 依訂單 Label 查 Code,對查到的紀錄逐筆呼叫 Reverse。
查無 Code 或 Code 尚未兌換時,helper 可結束;逾時則依有限次重試處理。
稽核有正向回饋與逆流程回收兩條 Job 鏈路。兩者都會碰到佔額,但檢查目的不同:正向確認應保留的額度存在、不得保留的額度不存在;逆流程確認取消或回收後,額度確實返還。
正向稽核:DDB 狀態與 PCPS 筆數
flowchart LR
F["正向稽核分派器"] --> T{"活動類型?"}
T -->|點數| FP["點數稽核 Job"]
T -->|券| FC["券稽核 Job"]
FP --> Q[PromotionRewardQuotaAuditor]
FC --> Q
Q --> DDB["讀 DDB 狀態"]
Q --> PCPS["查 PCPS 筆數"]
逆流程稽核:返還後是否留有額度
flowchart LR
R["逆流程稽核分派器"] --> RT{"回收類型?"}
RT -->|點數| RP["點數回收稽核 Job"]
RT -->|券| RC["券回收稽核 Job"]
RP --> RQ["點數佔額回收 Auditor"]
RC --> RCQ["券佔額回收 Auditor"]
RQ --> PCPS["查 PCPS 殘留額度"]
RCQ --> PCPS
| 鏈路 | 分派與執行 Job | 佔額相關檢查 |
|---|---|---|
| 正向回饋稽核 | AuditPromotionRewardLoyaltyPointsDispatchV2 依訂單來源及活動類型,分派 AuditPromotionRewardLoyaltyPointsV2/AuditPromotionRewardCoupon,或線下的 AuditCrmOthersOrderPromotionRewardLoyaltyPointsV2/AuditCrmOthersOrderPromotionRewardCoupon。四個 Job 都註冊 PromotionRewardQuotaAuditor。 |
以 DDB 回饋主單狀態對照 PCPS 查到的佔額筆數 |
| 逆流程回收稽核 | AuditPromotionRecycleDispatch 分派 AuditPromotionRecycleLoyaltyPoints/AuditPromotionRecycleCoupon,或對應的線下回收稽核 Job。點數與券分別使用 PromotionRecycleQuotaPointsRecordAuditor、PromotionRecycleQuotaCouponRecordAuditor。 |
檢查整單取消、DDB Cancel 或點數全回收後,PCPS 是否仍留有額度 |
線上正向分派器先找待稽核訂單,再依訂單時間區間找回饋活動並建立逐筆稽核 Task。線下批次分派器會另建線下正向與逆流程稽核 Task,BookingTime 分別設為建立當下加 6 小時 30 分及 10 小時;這是排程稽核,不是每次 dispatch-and-redeem 成功就立即稽核
正向佔額筆數規則。 PromotionRewardQuotaAuditor 只對規則有 GroupCode 的活動檢查。它按訂單來源決定 TG:線上用原 TG,線下加 CrmSalesOrderCodePrefix;再用 ShopId、會員、GroupCode、TG 呼叫 CheckOrderOccupiedQuota。該方法以 PCPS get-by-label 的回傳筆數計算,查無資料回 0
| DDB 主單狀態 | 預期 PCPS 佔額筆數 |
|---|---|
Occupy、WaitToReward |
恰好 1 筆 |
Reward,點數尚未全回收;或券回饋 |
恰好 1 筆 |
Reward,點數已全回收 |
0 筆 |
Unmatch、MatchWithoutQuota、Cancel |
0 筆 |
超出預期時,Auditor 會回報活動 ID、DDB 狀態、實際筆數與 groupId。這裡是計算 get-by-label 的查詢結果,**沒有在 Auditor 內逐筆驗證 Code 的 State**;查詢的 Limit 設為 30
同一批正向稽核中的其他檢查。 點數與券的紀錄 Auditor 都會指出「訂單已取消、失敗、出貨或完成,DDB 主單卻仍是 Occupy」;券還檢查 Occupy 時非 NoReward 子單是否為 WaitToReward。點數對線上佔額活動另查 DDB 回饋歷程是否出現過 Occupy,攤提舊版則跳過歷程檢查。PromotionRewardPerformanceAuditor 檢查應有的績效紀錄:WaitToReward、Reward 及曾經付款後才 Cancel 的紀錄須有績效;付款前 Occupy → Cancel 不要求績效
逆流程的返還檢查。 點數回收稽核在「線上訂單子單全數取消」時查 PCPS 是否仍有佔額,也會對 DDB Cancel 或 Reward 且點數全回收檢查是否仍有佔額;券回收稽核則對 DDB Cancel 檢查。這兩個 Auditor 都略過 Unmatch、MatchWithoutQuota 和沒有 GroupCode 的活動。若查到殘留佔額,會提示確認逆流程 Job 的返還是否完成。
排查一筆訂單時,可固定 ShopId + PromotionEngineId + TG + MemberId,依序核對活動 GroupCode、正向與逆流程 Job、DDB 狀態及歷程、PCPS 的 Code 筆數與 CodeId,再看績效 Job 是否應已建立。DDB 的 Occupy 代表系統記錄已佔額,實際是否仍占用額度仍須以 PCPS 查詢結果核對。
稽核判讀:CheckOrderOccupiedQuota 計算的是 PCPS get-by-label 回傳筆數,上限為 30;Auditor 本身沒有逐筆驗證 Code 的 State。


