[FEAT] 實作訂單 pending 狀態轉移與逾時交易自動清除機制

1. 修改 app/Models/Transaction/Order.php:新增訂單生命週期狀態常數與 TERMINAL_STATUSES 終態定義。
2. 修改 app/Services/Transaction/TransactionService.php:優化 processTransaction 與 finalizeTransaction 的冪等性邏輯,支援 pending 狀態訂單的狀態轉移,並拆分輔助函式 transitionOrder 與 createOrderItems。
3. 新增 app/Console/Commands/SweepAbandonedOrdersCommand.php:建立 orders:sweep-abandoned 指令,將停留在 pending 狀態超過 15 分鐘的逾時訂單標記為 abandoned。
4. 修改 app/Console/Kernel.php:將 orders:sweep-abandoned 定期清理指令註冊至 Schedule 排程中,設定每 5 分鐘執行一次。
This commit is contained in:
sky121113 2026-06-16 08:32:06 +08:00
parent d9edeb0d5d
commit b506d64dd3
4 changed files with 168 additions and 29 deletions

View File

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Models\Transaction\Order;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* 把「停留在 pending 太久」的交易標記為 abandoned感應前取消 / 付款未完成)。
*
* 設計重點(過渡期安全):
* - 只動 status = 'pending' 的單。線上 main 機台(晟崴/中國醫)從不送 pending
* 後台也就不會有它們的 pending 本命令永遠掃不到它們,零接觸。
* - 付款對話框逾時約 40 秒,預設門檻給到 15 分鐘,遠大於正常付款耗時;
* 超過此門檻仍 pending代表機台沒回成功/失敗取消、斷線、App 重啟),視為 abandoned。
* - 不碰庫存、不碰金流pending 本來就還沒出貨/扣庫存。
*/
class SweepAbandonedOrdersCommand extends Command
{
protected $signature = 'orders:sweep-abandoned {--minutes=15 : 停留在 pending 超過幾分鐘視為 abandoned} {--limit=500 : 單次處理筆數上限}';
protected $description = '將逾時停留在 pending 的交易標記為 abandoned';
public function handle(): int
{
$minutes = max(1, (int) $this->option('minutes'));
$limit = max(1, (int) $this->option('limit'));
$threshold = now()->subMinutes($minutes);
$orders = Order::withoutGlobalScopes()
->where('status', Order::STATUS_PENDING)
->where('created_at', '<', $threshold)
->orderBy('id')
->limit($limit)
->get();
if ($orders->isEmpty()) {
$this->info('No stale pending orders to sweep.');
return self::SUCCESS;
}
$swept = 0;
foreach ($orders as $order) {
$order->update(['status' => Order::STATUS_ABANDONED]);
$swept++;
}
Log::info('Swept abandoned orders', ['count' => $swept, 'older_than_minutes' => $minutes]);
$this->info("Marked {$swept} pending order(s) as abandoned (older than {$minutes} min).");
return self::SUCCESS;
}
}

View File

@ -16,6 +16,8 @@ class Kernel extends ConsoleKernel
$schedule->command('ota:process-schedules')->everyMinute();
// 電子發票對帳:對 pending 發票去綠界查證(補登 issued / 標 failed 待補開)
$schedule->command('invoices:reconcile')->everyFiveMinutes()->withoutOverlapping();
// 交易結案掃描:把逾時停留在 pending 的單標記為 abandoned只動 pending碰不到線上 main 機台)
$schedule->command('orders:sweep-abandoned')->everyFiveMinutes()->withoutOverlapping();
}
/**

View File

@ -22,6 +22,22 @@ class Order extends Model
public const DELIVERY_STATUS_SUCCESS = 1;
public const DELIVERY_STATUS_PARTIAL = 2;
// 交易生命週期狀態status 欄位,字串)。
// pending進入付款、等待結果completed付款成功出貨另看 delivery_status
// failed付款失敗/逾時abandoned感應前取消由排程把逾時 pending 標記)。
// ⚠ 終態 = [completed, failed, abandoned],一律冪等不可覆蓋(保護線上 main 機台行為不變)。
public const STATUS_PENDING = 'pending';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const STATUS_ABANDONED = 'abandoned';
/** 終態清單:到了這些狀態就不再變更(冪等)。 */
public const TERMINAL_STATUSES = [
self::STATUS_COMPLETED,
self::STATUS_FAILED,
self::STATUS_ABANDONED,
];
protected $fillable = [
'company_id',
'flow_id',

View File

@ -34,18 +34,32 @@ class TransactionService
{
$flowId = $data['flow_id'] ?? null;
// 交易生命週期狀態payload 沒帶就預設 completed。
// → main(晟崴/中國醫)的成功 finalize 不帶 status仍得到 'completed',行為逐字不變。
$incomingStatus = $data['status'] ?? Order::STATUS_COMPLETED;
if ($flowId) {
$existingOrder = Order::withoutGlobalScopes()
->where('flow_id', $flowId)
->first();
if ($existingOrder) {
// 鐵律二:終態(completed/failed/abandoned)一律冪等回傳,不可覆蓋。
// main 的單永遠是 completed → 等同原本「flow_id 已存在直接 return」的行為
// 重送/redelivery 也不會重複建立、不會重複扣庫存。
if (in_array($existingOrder->status, Order::TERMINAL_STATUSES, true)) {
return $existingOrder;
}
// 鐵律三:只有「既有單為 pending」才允許狀態轉移pending → completed/failed/abandoned
// 唯有「先送過 pending」的機台(本分支、flag on)會走到這main 從不產生 pending → 不可達。
return $this->transitionOrder($existingOrder, $data, $incomingStatus);
}
}
return DB::transaction(function () use ($data) {
return DB::transaction(function () use ($data, $incomingStatus) {
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
$paymentStatus = (int) ($data['payment_status'] ?? Order::PAYMENT_STATUS_SUCCESS);
// Create Order
$order = Order::create([
'company_id' => $machine->company_id,
@ -60,7 +74,7 @@ class TransactionService
'points_used' => $data['points_used'] ?? 0,
'original_amount' => $data['original_amount'] ?? $data['total_amount'],
'payment_type' => (int) ($data['payment_type'] ?? 0),
'payment_status' => $data['payment_status'] ?? 1,
'payment_status' => $paymentStatus,
'payment_request' => $data['payment_request'] ?? null,
'payment_response' => $data['payment_response'] ?? null,
'member_barcode' => $data['member_barcode'] ?? null,
@ -68,15 +82,66 @@ class TransactionService
'welcome_gift_id' => $data['welcome_gift_id'] ?? null,
'invoice_info' => $data['invoice_info'] ?? null,
'machine_time' => $data['machine_time'] ?? now(),
'payment_at' => now(),
'status' => 'completed',
// 付款成功才有 payment_atpending/failed 無實際付款時間。
// main = completed + payment_status=1 → now(),與原本一致。
'payment_at' => $paymentStatus === Order::PAYMENT_STATUS_SUCCESS ? now() : null,
'status' => $incomingStatus,
'metadata' => $data['metadata'] ?? null,
'delivery_status' => (int) ($data['delivery_status'] ?? Order::DELIVERY_STATUS_SUCCESS),
]);
// Create Order Items
if (!empty($data['items'])) {
foreach ($data['items'] as $item) {
$this->createOrderItems($order, $data['items'] ?? []);
return $order;
});
}
/**
* pending 單的狀態轉移pending completed/failed/abandoned
* 只更新付款結果相關欄位;商品明細在 pending 階段已建立,不重複建立(避免品項翻倍)。
*/
protected function transitionOrder(Order $order, array $data, string $newStatus): Order
{
return DB::transaction(function () use ($order, $data, $newStatus) {
$paymentStatus = isset($data['payment_status'])
? (int) $data['payment_status']
: (int) $order->payment_status;
$order->update([
'status' => $newStatus,
'payment_status' => $paymentStatus,
// payment_type 以本次上報為準:例如手機支付 pending 階段為 4(路由鍵)
// 成功 finalize 覆寫為 10(手機支付),轉移時需一併更新。
'payment_type' => isset($data['payment_type'])
? (int) $data['payment_type']
: $order->payment_type,
'payment_request' => $data['payment_request'] ?? $order->payment_request,
'payment_response' => $data['payment_response'] ?? $order->payment_response,
'pay_amount' => $data['pay_amount'] ?? $order->pay_amount,
'change_amount' => $data['change_amount'] ?? $order->change_amount,
'points_used' => $data['points_used'] ?? $order->points_used,
'invoice_info' => $data['invoice_info'] ?? $order->invoice_info,
'delivery_status' => isset($data['delivery_status'])
? (int) $data['delivery_status']
: $order->delivery_status,
'payment_at' => $paymentStatus === Order::PAYMENT_STATUS_SUCCESS
? ($order->payment_at ?? now())
: $order->payment_at,
]);
// pending 階段若未帶明細,補建一次(防禦)。
if ($order->items()->count() === 0 && !empty($data['items'])) {
$this->createOrderItems($order, $data['items']);
}
return $order->fresh();
});
}
/** 建立訂單品項(沒帶名稱時回資料庫補抓)。 */
protected function createOrderItems(Order $order, array $items): void
{
foreach ($items as $item) {
$productName = $item['product_name'] ?? null;
// 如果沒傳名稱,嘗試從資料庫抓取
@ -96,10 +161,6 @@ class TransactionService
}
}
return $order;
});
}
/**
* Generate a unique order number.
*/
@ -274,11 +335,17 @@ class TransactionService
// 優先從 Root 讀取,再從 order 內讀取
$flowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null);
// 冪等性檢查:如果 flow_id 已經存在,直接回傳既有訂單
// 冪等性檢查:只有「終態(completed/failed/abandoned)」才直接回傳,避免重複出貨/扣庫存。
// main(晟崴/中國醫)的單永遠是 completed → 等同原本的 early-return行為不變。
// 既有單為 pending本分支先送過 pending時則「放行」往下交給 processTransaction
// 做 pending → paid/failed 的狀態轉移(含本次的 dispense/invoice
if ($flowId) {
$existingOrder = Order::withoutGlobalScopes()->where('flow_id', $flowId)->first();
if ($existingOrder) {
Log::info("Transaction already finalized, returning existing order", ['flow_id' => $flowId]);
if ($existingOrder && in_array($existingOrder->status, Order::TERMINAL_STATUSES, true)) {
Log::info("Transaction already finalized (terminal), returning existing order", [
'flow_id' => $flowId,
'status' => $existingOrder->status,
]);
return $existingOrder;
}
} else {