From b506d64dd3466bfadb0458061c6931ae270cbc16 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Tue, 16 Jun 2026 08:32:06 +0800 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=E5=AF=A6=E4=BD=9C=E8=A8=82=E5=96=AE?= =?UTF-8?q?=20pending=20=E7=8B=80=E6=85=8B=E8=BD=89=E7=A7=BB=E8=88=87?= =?UTF-8?q?=E9=80=BE=E6=99=82=E4=BA=A4=E6=98=93=E8=87=AA=E5=8B=95=E6=B8=85?= =?UTF-8?q?=E9=99=A4=E6=A9=9F=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 分鐘執行一次。 --- .../Commands/SweepAbandonedOrdersCommand.php | 54 ++++++++ app/Console/Kernel.php | 2 + app/Models/Transaction/Order.php | 16 +++ .../Transaction/TransactionService.php | 125 ++++++++++++++---- 4 files changed, 168 insertions(+), 29 deletions(-) create mode 100644 app/Console/Commands/SweepAbandonedOrdersCommand.php diff --git a/app/Console/Commands/SweepAbandonedOrdersCommand.php b/app/Console/Commands/SweepAbandonedOrdersCommand.php new file mode 100644 index 0000000..4f69bac --- /dev/null +++ b/app/Console/Commands/SweepAbandonedOrdersCommand.php @@ -0,0 +1,54 @@ +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; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 4f0822f..52d8087 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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(); } /** diff --git a/app/Models/Transaction/Order.php b/app/Models/Transaction/Order.php index 3c28ae3..f2b1b48 100644 --- a/app/Models/Transaction/Order.php +++ b/app/Models/Transaction/Order.php @@ -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', diff --git a/app/Services/Transaction/TransactionService.php b/app/Services/Transaction/TransactionService.php index cb7d0e3..c9f8380 100644 --- a/app/Services/Transaction/TransactionService.php +++ b/app/Services/Transaction/TransactionService.php @@ -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) { - 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(); + $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,38 +82,85 @@ 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_at;pending/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) { - $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'], - ]); - } - } + $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; + + // 如果沒傳名稱,嘗試從資料庫抓取 + 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. */ @@ -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 {