[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:
parent
d9edeb0d5d
commit
b506d64dd3
54
app/Console/Commands/SweepAbandonedOrdersCommand.php
Normal file
54
app/Console/Commands/SweepAbandonedOrdersCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,6 +16,8 @@ class Kernel extends ConsoleKernel
|
|||||||
$schedule->command('ota:process-schedules')->everyMinute();
|
$schedule->command('ota:process-schedules')->everyMinute();
|
||||||
// 電子發票對帳:對 pending 發票去綠界查證(補登 issued / 標 failed 待補開)
|
// 電子發票對帳:對 pending 發票去綠界查證(補登 issued / 標 failed 待補開)
|
||||||
$schedule->command('invoices:reconcile')->everyFiveMinutes()->withoutOverlapping();
|
$schedule->command('invoices:reconcile')->everyFiveMinutes()->withoutOverlapping();
|
||||||
|
// 交易結案掃描:把逾時停留在 pending 的單標記為 abandoned(只動 pending,碰不到線上 main 機台)
|
||||||
|
$schedule->command('orders:sweep-abandoned')->everyFiveMinutes()->withoutOverlapping();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -22,6 +22,22 @@ class Order extends Model
|
|||||||
public const DELIVERY_STATUS_SUCCESS = 1;
|
public const DELIVERY_STATUS_SUCCESS = 1;
|
||||||
public const DELIVERY_STATUS_PARTIAL = 2;
|
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 = [
|
protected $fillable = [
|
||||||
'company_id',
|
'company_id',
|
||||||
'flow_id',
|
'flow_id',
|
||||||
|
|||||||
@ -34,18 +34,32 @@ class TransactionService
|
|||||||
{
|
{
|
||||||
$flowId = $data['flow_id'] ?? null;
|
$flowId = $data['flow_id'] ?? null;
|
||||||
|
|
||||||
|
// 交易生命週期狀態:payload 沒帶就預設 completed。
|
||||||
|
// → main(晟崴/中國醫)的成功 finalize 不帶 status,仍得到 'completed',行為逐字不變。
|
||||||
|
$incomingStatus = $data['status'] ?? Order::STATUS_COMPLETED;
|
||||||
|
|
||||||
if ($flowId) {
|
if ($flowId) {
|
||||||
$existingOrder = Order::withoutGlobalScopes()
|
$existingOrder = Order::withoutGlobalScopes()
|
||||||
->where('flow_id', $flowId)
|
->where('flow_id', $flowId)
|
||||||
->first();
|
->first();
|
||||||
if ($existingOrder) {
|
if ($existingOrder) {
|
||||||
return $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();
|
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
|
||||||
|
|
||||||
|
$paymentStatus = (int) ($data['payment_status'] ?? Order::PAYMENT_STATUS_SUCCESS);
|
||||||
|
|
||||||
// Create Order
|
// Create Order
|
||||||
$order = Order::create([
|
$order = Order::create([
|
||||||
'company_id' => $machine->company_id,
|
'company_id' => $machine->company_id,
|
||||||
@ -60,7 +74,7 @@ class TransactionService
|
|||||||
'points_used' => $data['points_used'] ?? 0,
|
'points_used' => $data['points_used'] ?? 0,
|
||||||
'original_amount' => $data['original_amount'] ?? $data['total_amount'],
|
'original_amount' => $data['original_amount'] ?? $data['total_amount'],
|
||||||
'payment_type' => (int) ($data['payment_type'] ?? 0),
|
'payment_type' => (int) ($data['payment_type'] ?? 0),
|
||||||
'payment_status' => $data['payment_status'] ?? 1,
|
'payment_status' => $paymentStatus,
|
||||||
'payment_request' => $data['payment_request'] ?? null,
|
'payment_request' => $data['payment_request'] ?? null,
|
||||||
'payment_response' => $data['payment_response'] ?? null,
|
'payment_response' => $data['payment_response'] ?? null,
|
||||||
'member_barcode' => $data['member_barcode'] ?? null,
|
'member_barcode' => $data['member_barcode'] ?? null,
|
||||||
@ -68,38 +82,85 @@ class TransactionService
|
|||||||
'welcome_gift_id' => $data['welcome_gift_id'] ?? null,
|
'welcome_gift_id' => $data['welcome_gift_id'] ?? null,
|
||||||
'invoice_info' => $data['invoice_info'] ?? null,
|
'invoice_info' => $data['invoice_info'] ?? null,
|
||||||
'machine_time' => $data['machine_time'] ?? now(),
|
'machine_time' => $data['machine_time'] ?? now(),
|
||||||
'payment_at' => now(),
|
// 付款成功才有 payment_at;pending/failed 無實際付款時間。
|
||||||
'status' => 'completed',
|
// main = completed + payment_status=1 → now(),與原本一致。
|
||||||
|
'payment_at' => $paymentStatus === Order::PAYMENT_STATUS_SUCCESS ? now() : null,
|
||||||
|
'status' => $incomingStatus,
|
||||||
'metadata' => $data['metadata'] ?? null,
|
'metadata' => $data['metadata'] ?? null,
|
||||||
'delivery_status' => (int) ($data['delivery_status'] ?? Order::DELIVERY_STATUS_SUCCESS),
|
'delivery_status' => (int) ($data['delivery_status'] ?? Order::DELIVERY_STATUS_SUCCESS),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Create Order Items
|
$this->createOrderItems($order, $data['items'] ?? []);
|
||||||
if (!empty($data['items'])) {
|
|
||||||
foreach ($data['items'] as $item) {
|
|
||||||
$productName = $item['product_name'] ?? null;
|
|
||||||
|
|
||||||
// 如果沒傳名稱,嘗試從資料庫抓取
|
|
||||||
if (empty($productName)) {
|
|
||||||
$product = \App\Models\Product\Product::find($item['product_id']);
|
|
||||||
$productName = $product?->name ?? 'Unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
$order->items()->create([
|
|
||||||
'product_id' => $item['product_id'],
|
|
||||||
'product_name' => $productName,
|
|
||||||
'barcode' => $item['barcode'] ?? null,
|
|
||||||
'price' => $item['price'],
|
|
||||||
'quantity' => $item['quantity'],
|
|
||||||
'subtotal' => $item['price'] * $item['quantity'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $order;
|
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;
|
||||||
|
|
||||||
|
// 如果沒傳名稱,嘗試從資料庫抓取
|
||||||
|
if (empty($productName)) {
|
||||||
|
$product = \App\Models\Product\Product::find($item['product_id']);
|
||||||
|
$productName = $product?->name ?? 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->items()->create([
|
||||||
|
'product_id' => $item['product_id'],
|
||||||
|
'product_name' => $productName,
|
||||||
|
'barcode' => $item['barcode'] ?? null,
|
||||||
|
'price' => $item['price'],
|
||||||
|
'quantity' => $item['quantity'],
|
||||||
|
'subtotal' => $item['price'] * $item['quantity'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique order number.
|
* Generate a unique order number.
|
||||||
*/
|
*/
|
||||||
@ -274,11 +335,17 @@ class TransactionService
|
|||||||
// 優先從 Root 讀取,再從 order 內讀取
|
// 優先從 Root 讀取,再從 order 內讀取
|
||||||
$flowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null);
|
$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) {
|
if ($flowId) {
|
||||||
$existingOrder = Order::withoutGlobalScopes()->where('flow_id', $flowId)->first();
|
$existingOrder = Order::withoutGlobalScopes()->where('flow_id', $flowId)->first();
|
||||||
if ($existingOrder) {
|
if ($existingOrder && in_array($existingOrder->status, Order::TERMINAL_STATUSES, true)) {
|
||||||
Log::info("Transaction already finalized, returning existing order", ['flow_id' => $flowId]);
|
Log::info("Transaction already finalized (terminal), returning existing order", [
|
||||||
|
'flow_id' => $flowId,
|
||||||
|
'status' => $existingOrder->status,
|
||||||
|
]);
|
||||||
return $existingOrder;
|
return $existingOrder;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user