star-cloud/tests/Feature/Transaction/FinalizeIdempotencyTest.php
sky121113 ae4556584f [FEAT] 銷售紀錄流水號顯示剝除機台序號前綴
1. 新增 HasDisplayFlowId trait,提供 display_flow_id accessor:僅顯示時剝除 flow_id 開頭的機台序號前綴,DB 仍存完整 serial+flow_id 唯一值供去重/冪等/對帳判斷。
2. Order 與 Invoice 兩 Model 套用此 trait。
3. 訂單詳情面板 (order-detail-panel) 與發票匯出 (SalesController) 改顯示 display_flow_id,呈現乾淨易讀的機台流水號。
4. accessor 具防禦性:機台關聯為 null 或舊的未前綴資料皆原樣顯示,不誤剝。
5. 新增測試驗證 display_flow_id 正確剝除序號前綴且 DB 值維持完整。

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

166 lines
6.8 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);
}
/** display_flow_id 顯示用 accessor:剝除機台序號前綴,DB 仍存完整 serial+raw 唯一值。 */
public function test_display_flow_id_strips_serial_prefix(): void
{
$serial = '2501000062';
$raw = '202606190839470011';
$this->makeMachine($serial);
$order = $this->service->finalizeTransaction($this->completedPayload($serial, $raw));
// DB 值維持完整 28 碼;顯示值剝掉序號前綴 → 乾淨 18 碼
$this->assertEquals($serial . $raw, $order->flow_id);
$this->assertEquals($raw, $order->fresh()->display_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());
}
}