star-cloud/tests/Feature/Transaction/FinalizeIdempotencyTest.php
sky121113 ddac862cee [FEAT] 交易結算冪等性強化與 flow_id 跨機台命名空間
1. finalizeTransaction 將 flow_id 前綴機台序號(serial+rawFlowId)使其全域唯一,解決跨機台單機 flow_id 撞號問題;ACK 仍回傳原始 flow_id,App/outbox 對帳無感。
2. 終態冪等檢查移入 DB transaction 並對既有 order 加 lockForUpdate 排他鎖,防止兩個 worker 同時穿透 pending 而重複出貨/扣庫存/核銷。
3. 取貨碼(PickupCode)與來店禮(WelcomeGift)核銷加排他鎖,防並發 lost-update 使 usage_count 突破 usage_limit。
4. recordInvoice 的 RelateNumber 推導改為直接取用已前綴序號的 flow_id,與線上既有 serial+rawFlowId 逐字等價,不重複加序號。
5. 新增 FinalizeIdempotencyTest、FinalizeStockMysqlTest 與 phpunit.mysql.xml(MySQL-only DDL 測試設定)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:14:19 +08:00

152 lines
6.2 KiB
PHP

<?php
namespace Tests\Feature\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\Transaction\DispenseRecord;
use App\Models\Transaction\Invoice;
use App\Models\Transaction\Order;
use App\Services\Transaction\TransactionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 交易 finalize 的冪等性、跨機台防撞號與 RelateNumber 等價性測試。
*
* 對應改動:flow_id 入口以 serial 前綴命名空間化(無分隔符)+ 終態短路移入交易內加排他鎖。
* 註:sqlite :memory: 無真實併發,故此處驗的是「順序重送」的冪等(由終態短路保證);
* lockForUpdate 的真實併發效果需 MySQL 環境另測。
*/
class FinalizeIdempotencyTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private TransactionService $service;
protected function setUp(): void
{
parent::setUp();
$this->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());
}
}