diff --git a/app/Services/Transaction/TransactionService.php b/app/Services/Transaction/TransactionService.php index b6d1832..f96e78f 100644 --- a/app/Services/Transaction/TransactionService.php +++ b/app/Services/Transaction/TransactionService.php @@ -208,10 +208,12 @@ class TransactionService } } - // RelateNumber 冪等鍵:App 未帶時由後台以 serial + flow_id 推導(綠界上限 30 字) + // RelateNumber 冪等鍵:App 未帶時由後台以 flow_id 推導(綠界上限 30 字、限英數)。 + // flow_id 在 finalizeTransaction 入口已前綴機台序號(serial+rawFlowId), + // 故此處直接取用即可,輸出與線上既有 serial+rawFlowId 逐字等價,不可再重複加 serial。 $relateNumber = $data['relate_number'] ?? null; if (empty($relateNumber) && !empty($data['flow_id'])) { - $relateNumber = substr($machine->serial_no . $data['flow_id'], 0, 30); + $relateNumber = substr($data['flow_id'], 0, 30); } $attributes = [ @@ -333,14 +335,30 @@ class TransactionService public function finalizeTransaction(array $data): Order { // 優先從 Root 讀取,再從 order 內讀取 - $flowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null); + $rawFlowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null); + if (empty($rawFlowId)) { + throw new \Exception("Flow ID is required for finalization"); + } - // 冪等性檢查:只有「終態(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(); + // 跨機台防撞號:App 端 flow_id 僅單機唯一(YYYYMMDDHHMMSSXXXX),加上機台序號前綴 + // 才全域唯一(orders.flow_id 是全域 unique)。無分隔符——綠界 RelateNumber 禁特殊符號, + // 且 serial+flow_id 與線上既有 relateNumber 推導逐字等價(見 recordInvoice)。 + // ACK 仍由 ProcessTransactionFinalized 回傳『原始』flow_id,App / outbox 對帳無感。 + $flowId = $data['serial_no'] . $rawFlowId; + $data['flow_id'] = $flowId; + $data['order']['flow_id'] = $flowId; + + return DB::transaction(function () use ($data, $flowId) { + $serialNo = $data['serial_no']; + + // 冪等性 + 併發防護:終態檢查移進交易內並對既有 row 加排他鎖, + // 防止兩個 worker 同時穿透 pending 狀態而重複出貨/扣庫存/核銷。 + // main(晟崴/中國醫)的單永遠是 completed → 等同原本的 early-return,行為不變。 + // 既有單為 pending 時放行,往下交給 processTransaction 做 pending → paid/failed 轉移。 + $existingOrder = Order::withoutGlobalScopes() + ->where('flow_id', $flowId) + ->lockForUpdate() + ->first(); if ($existingOrder && in_array($existingOrder->status, Order::TERMINAL_STATUSES, true)) { Log::info("Transaction already finalized (terminal), returning existing order", [ 'flow_id' => $flowId, @@ -348,12 +366,6 @@ class TransactionService ]); return $existingOrder; } - } else { - throw new \Exception("Flow ID is required for finalization"); - } - - return DB::transaction(function () use ($data, $flowId) { - $serialNo = $data['serial_no']; // 1. Process Order (B600) $orderData = $data['order']; @@ -433,9 +445,11 @@ class TransactionService break; case 6: // 取貨碼 (Pickup Code) + // 排他鎖:防並發核銷 lost-update 使 usage_count 突破 usage_limit。 $pickupCode = PickupCode::where('machine_id', $order->machine_id) ->where('id', $codeId) ->where('status', 'active') + ->lockForUpdate() ->first(); if ($pickupCode) { $newCount = $pickupCode->usage_count + 1; @@ -483,9 +497,11 @@ class TransactionService // 處理來店禮 (Welcome Gift) 自動核銷 $welcomeGiftId = $data['order']['welcome_gift_id'] ?? ($data['welcome_gift_id'] ?? null); if ($welcomeGiftId) { + // 排他鎖:防並發核銷 lost-update 使 usage_count 突破 usage_limit。 $welcomeGift = WelcomeGift::where('machine_id', $order->machine_id) ->where('id', $welcomeGiftId) ->where('status', 'active') + ->lockForUpdate() ->first(); if ($welcomeGift) { $newCount = $welcomeGift->usage_count + 1; diff --git a/phpunit.mysql.xml b/phpunit.mysql.xml new file mode 100644 index 0000000..c08c61c --- /dev/null +++ b/phpunit.mysql.xml @@ -0,0 +1,37 @@ + + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + diff --git a/tests/Feature/Transaction/FinalizeIdempotencyTest.php b/tests/Feature/Transaction/FinalizeIdempotencyTest.php new file mode 100644 index 0000000..684610a --- /dev/null +++ b/tests/Feature/Transaction/FinalizeIdempotencyTest.php @@ -0,0 +1,151 @@ +company = Company::create(['name' => 'Test Company']); + $this->service = app(TransactionService::class); + } + + private function makeMachine(string $serialNo, bool $taxInvoice = false): Machine + { + return Machine::factory()->create([ + 'company_id' => $this->company->id, + 'serial_no' => $serialNo, + 'tax_invoice_enabled' => $taxInvoice, + ]); + } + + private function completedPayload(string $serialNo, string $rawFlowId, array $dispense = []): array + { + return [ + 'serial_no' => $serialNo, + 'flow_id' => $rawFlowId, + 'payment_type' => 1, + 'order' => [ + 'flow_id' => $rawFlowId, + 'total_amount' => 50, + 'pay_amount' => 50, + 'payment_type' => 1, + 'status' => 'completed', + 'payment_status' => 1, + ], + 'dispense' => $dispense, + ]; + } + + /** 順序重送同一筆 → 只建一張單、出貨一次、庫存只扣一次。 */ + public function test_resending_same_transaction_is_idempotent(): void + { + $serial = '1234567890'; + $raw = '202606181530450001'; + $this->makeMachine($serial); + + // 不建貨道:recordDispense 在 slot=null 時仍建出貨紀錄,但跳過 decrement/stockMovement + // (stockMovement 的 type enum 是 MySQL-only DDL,sqlite :memory: 無法驗,屬既有環境限制)。 + // 「出貨紀錄只一筆」與「庫存只扣一次」由同一道終態短路保證,此處以前者代證。 + $dispense = [[ + 'slot_no' => '99', + 'product_id' => 101, + 'amount' => 50, + 'dispense_status' => 1, + 'remaining_stock' => 4, + ]]; + + $first = $this->service->finalizeTransaction($this->completedPayload($serial, $raw, $dispense)); + $second = $this->service->finalizeTransaction($this->completedPayload($serial, $raw, $dispense)); + + // 同一張單(終態短路)、出貨紀錄只有一筆(重送不重複扣庫存的同源保證) + $this->assertEquals($first->id, $second->id); + $this->assertEquals(1, Order::where('flow_id', $serial . $raw)->count()); + $this->assertEquals(1, DispenseRecord::where('flow_id', $serial . $raw)->count()); + } + + /** 不同機台、相同原始 flow_id → 命名空間化後為兩張獨立單,不互吞。 */ + public function test_same_raw_flow_id_across_machines_creates_separate_orders(): void + { + $raw = '202606181530450001'; + $machineA = $this->makeMachine('1111111111'); + $machineB = $this->makeMachine('2222222222'); + + $orderA = $this->service->finalizeTransaction($this->completedPayload('1111111111', $raw)); + $orderB = $this->service->finalizeTransaction($this->completedPayload('2222222222', $raw)); + + $this->assertNotEquals($orderA->id, $orderB->id); + $this->assertEquals('1111111111' . $raw, $orderA->flow_id); + $this->assertEquals('2222222222' . $raw, $orderB->flow_id); + } + + /** RelateNumber = serial + rawFlowId(無分隔符、≤30、與線上推導逐字等價,不可雙重 serial)。 */ + public function test_relate_number_equals_serial_plus_raw_flow_id(): void + { + $serial = '1234567890'; + $raw = '202606181530450001'; + $this->makeMachine($serial, taxInvoice: true); + + $payload = $this->completedPayload($serial, $raw); + $payload['invoice'] = ['status' => 'pending', 'amount' => 50]; + + $order = $this->service->finalizeTransaction($payload); + $invoice = Invoice::where('flow_id', $serial . $raw)->first(); + + $this->assertNotNull($invoice); + $this->assertEquals($serial . $raw, $invoice->relate_number); + $this->assertLessThanOrEqual(30, strlen($invoice->relate_number)); + // 不可出現雙重 serial 前綴 + $this->assertStringStartsNotWith($serial . $serial, $invoice->relate_number); + } + + /** pending 先送、completed 後送 → 同一張單狀態轉移(命名空間化前後一致)。 */ + public function test_pending_then_completed_transitions_same_order(): void + { + $serial = '1234567890'; + $raw = '202606181530450001'; + $this->makeMachine($serial); + + $pending = $this->completedPayload($serial, $raw); + $pending['order']['status'] = 'pending'; + $pending['order']['payment_status'] = 0; + $pending['dispense'] = []; + $pendingOrder = $this->service->finalizeTransaction($pending); + $this->assertEquals('pending', $pendingOrder->status); + + // slot_no=99 不存在 → 跳過 stockMovement(MySQL-only),仍建出貨紀錄 + $completed = $this->completedPayload($serial, $raw, [[ + 'slot_no' => '99', 'product_id' => 101, 'amount' => 50, + 'dispense_status' => 1, 'remaining_stock' => 4, + ]]); + $completedOrder = $this->service->finalizeTransaction($completed); + + // 同一張單(命名空間化前後 key 一致)、轉為終態、出貨紀錄只一筆 + $this->assertEquals($pendingOrder->id, $completedOrder->id); + $this->assertEquals('completed', $completedOrder->fresh()->status); + $this->assertEquals(1, Order::where('flow_id', $serial . $raw)->count()); + $this->assertEquals(1, DispenseRecord::where('flow_id', $serial . $raw)->count()); + } +} diff --git a/tests/Feature/Transaction/FinalizeStockMysqlTest.php b/tests/Feature/Transaction/FinalizeStockMysqlTest.php new file mode 100644 index 0000000..f002fcd --- /dev/null +++ b/tests/Feature/Transaction/FinalizeStockMysqlTest.php @@ -0,0 +1,94 @@ +getDriverName() !== 'mysql') { + $this->markTestSkipped('需 MySQL:machine_stock_movements.type enum 為 MySQL-only DDL。'); + } + + $this->company = Company::create(['name' => 'Test Company']); + $this->service = app(TransactionService::class); + } + + private function completedPayloadWithDispense(string $serialNo, string $rawFlowId, string $slotNo): array + { + return [ + 'serial_no' => $serialNo, + 'flow_id' => $rawFlowId, + 'payment_type' => 1, + 'order' => [ + 'flow_id' => $rawFlowId, + 'total_amount' => 50, + 'pay_amount' => 50, + 'payment_type' => 1, + 'status' => 'completed', + 'payment_status' => 1, + ], + 'dispense' => [[ + 'slot_no' => $slotNo, + 'product_id' => 101, + 'amount' => 50, + 'dispense_status' => 1, + 'remaining_stock' => 4, + ]], + ]; + } + + /** 出貨成功扣庫存一次;重送同一筆 → 庫存不再被扣、出貨/異動紀錄不重複(終態短路 + 命名空間化)。 */ + public function test_dispense_decrements_stock_once_and_resend_is_idempotent(): void + { + $serial = '1234567890'; + $raw = '202606181530450001'; + $machine = Machine::factory()->create([ + 'company_id' => $this->company->id, + 'serial_no' => $serial, + ]); + $slot = $machine->slots()->create(['slot_no' => '1', 'stock' => 5, 'max_stock' => 10]); + + $payload = $this->completedPayloadWithDispense($serial, $raw, '1'); + + // 第一次:扣庫存一次 + $first = $this->service->finalizeTransaction($payload); + $this->assertEquals(4, $slot->fresh()->stock); + $this->assertEquals(1, DispenseRecord::where('flow_id', $serial . $raw)->count()); + $this->assertEquals(1, DB::table('machine_stock_movements') + ->where('slot_no', '1')->where('machine_id', $machine->id)->count()); + + // 重送:庫存維持 4,紀錄不重複 + $second = $this->service->finalizeTransaction($payload); + $this->assertEquals($first->id, $second->id); + $this->assertEquals(4, $slot->fresh()->stock, '重送不可再扣庫存'); + $this->assertEquals(1, DispenseRecord::where('flow_id', $serial . $raw)->count()); + $this->assertEquals(1, DB::table('machine_stock_movements') + ->where('slot_no', '1')->where('machine_id', $machine->id)->count()); + $this->assertEquals(1, Order::where('flow_id', $serial . $raw)->count()); + } +}