[RELEASE] demo → main:交易冪等強化與銷售紀錄未付款單顯示修正正式上線

1. [FEAT] 交易結算冪等性強化與 flow_id 跨機台命名空間:finalizeTransaction 加機台序號前綴使全域唯一、終態檢查移入交易並加排他鎖防重複出貨/扣庫存/核銷、取貨碼與來店禮核銷防並發超量、含 FinalizeIdempotency/FinalizeStockMysql 測試。
2. [FIX] 銷售紀錄未付款單顯示:付款狀態「已棄單」改為「未支付」、非付款成功(未完成/支付失敗/未支付)的單出貨狀態統一顯示「--」,避免 delivery_status 預設值誤顯示為出貨成功;判斷收斂至 Order::hasDeliveryOutcome(),三語系對齊。
This commit is contained in:
sky121113 2026-06-18 17:29:21 +08:00
commit 8a7cb4fcf7
11 changed files with 350 additions and 21 deletions

View File

@ -935,6 +935,8 @@ class SalesController extends Controller
'pending' => __('Pending'),
'paid' => __('Paid'),
'completed' => __('Completed'),
'failed' => __('Failed'),
'abandoned' => __('Unpaid'),
'cancelled' => __('Cancelled'),
'refunded' => __('Refunded'),
];

View File

@ -229,11 +229,25 @@ class Order extends Model
];
}
/**
* 出貨狀態是否有意義:唯有付款成功(completed/paid)、機台真的動作過的單才有出貨結果。
* pending(未完成)、failed(支付失敗)、abandoned(未支付)從未出貨,出貨狀態應顯示 '--'
* delivery_status 欄位 DB 預設為 1(出貨成功),這些單若直接讀值會誤顯示成「出貨成功」。
*/
public function hasDeliveryOutcome(): bool
{
return in_array($this->status, [self::STATUS_COMPLETED, 'paid'], true);
}
/**
* 取得目前出貨狀態標籤 (Accessor)
*/
public function getDeliveryStatusLabelAttribute(): string
{
if (! $this->hasDeliveryOutcome()) {
return '--';
}
return self::getDeliveryStatusLabels()[$this->delivery_status] ?? __('Unknown');
}
}

View File

@ -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_idApp / 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;

View File

@ -27,7 +27,6 @@
"APP Version": "APP Version",
"APP_ID": "APP_ID",
"APP_KEY": "APP_KEY",
"Abandoned": "Abandoned",
"Abnormal": "Abnormal",
"Account": "Account",
"Account :name status has been changed to :status.": "Account :name status has been changed to :status.",
@ -2244,6 +2243,7 @@
"Unlock": "Unlock",
"Unlock Now": "Unlock Now",
"Unlock Page": "Unlock Page",
"Unpaid": "Unpaid",
"Unpublish": "Unpublish",
"Update": "Update",
"Update App": "Update App",

View File

@ -27,7 +27,6 @@
"APP Version": "アプリバージョン",
"APP_ID": "APP_ID",
"APP_KEY": "APP_KEY",
"Abandoned": "放棄済み",
"Abnormal": "異常",
"Account": "アカウント",
"Account :name status has been changed to :status.": "アカウント :name のステータスが :status に変更されました。",
@ -2244,6 +2243,7 @@
"Unlock": "ロック解除",
"Unlock Now": "今すぐロック解除",
"Unlock Page": "ページロック解除",
"Unpaid": "未払い",
"Unpublish": "非公開",
"Update": "更新",
"Update App": "アプリ更新",

View File

@ -27,7 +27,6 @@
"APP Version": "APP版本號",
"APP_ID": "APP_ID",
"APP_KEY": "APP_KEY",
"Abandoned": "已棄單",
"Abnormal": "異常",
"Account": "帳號",
"Account :name status has been changed to :status.": "帳號 :name 的狀態已變更為 :status。",
@ -2244,6 +2243,7 @@
"Unlock": "解鎖",
"Unlock Now": "立即解鎖",
"Unlock Page": "頁面解鎖",
"Unpaid": "未支付",
"Unpublish": "下架",
"Update": "編輯",
"Update App": "更新 App",

37
phpunit.mysql.xml Normal file
View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
本機 MySQL 測試設定:跑需要 MySQL-only DDL(如 machine_stock_movements.type enum)的測試。
用法:./vendor/bin/sail test -c phpunit.mysql.xml
使用 Sail 自動建立的 `testing` 資料庫;RefreshDatabase 會在其上跑 migration。
-->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value="testing"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>

View File

@ -44,7 +44,7 @@
'paid' => __('Paid'),
'completed' => __('Completed'),
'failed' => __('Failed'),
'abandoned' => __('Abandoned'),
'abandoned' => __('Unpaid'),
'cancelled' => __('Cancelled'),
'refunded' => __('Refunded'),
];
@ -63,6 +63,10 @@
<div class="space-y-2">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Dispense Status') }}</p>
<div>
{{-- 非付款成功(未完成/支付失敗/未支付)的單從未出貨,出貨狀態無意義 顯示 '--' --}}
@if(! $order->hasDeliveryOutcome())
<span class="text-sm font-extrabold text-slate-300 dark:text-slate-700 tracking-widest">--</span>
@else
@php
$deliveryStatusMap = [
0 => ['label' => __('Dispense Failed'), 'color' => 'rose'],
@ -72,6 +76,7 @@
$ds = $deliveryStatusMap[$order->delivery_status] ?? ['label' => __('Unknown'), 'color' => 'slate'];
@endphp
<x-status-badge :color="$ds['color']" :label="$ds['label']" size="sm" />
@endif
</div>
</div>
</div>

View File

@ -268,7 +268,7 @@
'paid' => ['label' => __('Paid'), 'color' => 'emerald'],
'completed' => ['label' => __('Completed'), 'color' => 'emerald'],
'failed' => ['label' => __('Failed'), 'color' => 'rose'],
'abandoned' => ['label' => __('Abandoned'), 'color' => 'slate'],
'abandoned' => ['label' => __('Unpaid'), 'color' => 'slate'],
'cancelled' => ['label' => __('Cancelled'), 'color' => 'slate'],
'refunded' => ['label' => __('Refunded'), 'color' => 'rose'],
];
@ -277,6 +277,10 @@
<x-status-badge :color="$s['color']" :label="$s['label']" size="xs" />
</td>
<td class="px-6 py-6 whitespace-nowrap">
{{-- 非付款成功(未完成/支付失敗/未支付)的單從未出貨,出貨狀態無意義 顯示 '--' --}}
@if(! $order->hasDeliveryOutcome())
<span class="text-xs font-bold text-slate-300 dark:text-slate-700 tracking-widest">--</span>
@else
@php
$deliveryStatusMap = [
0 => ['label' => __('Dispense Failed'), 'color' => 'rose'],
@ -286,6 +290,7 @@
$ds = $deliveryStatusMap[$order->delivery_status] ?? ['label' => __('Unknown'), 'color' => 'slate'];
@endphp
<x-status-badge :color="$ds['color']" :label="$ds['label']" size="xs" />
@endif
</td>
<td class="px-6 py-6 whitespace-nowrap">
@php
@ -351,7 +356,7 @@
'paid' => ['label' => __('Paid'), 'color' => 'emerald'],
'completed' => ['label' => __('Completed'), 'color' => 'emerald'],
'failed' => ['label' => __('Failed'), 'color' => 'rose'],
'abandoned' => ['label' => __('Abandoned'), 'color' => 'slate'],
'abandoned' => ['label' => __('Unpaid'), 'color' => 'slate'],
'cancelled' => ['label' => __('Cancelled'), 'color' => 'slate'],
'refunded' => ['label' => __('Refunded'), 'color' => 'rose'],
];
@ -451,6 +456,10 @@
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{
__('Dispense Status') }}</p>
{{-- 非付款成功(未完成/支付失敗/未支付)的單從未出貨,出貨狀態無意義 顯示 '--' --}}
@if(! $order->hasDeliveryOutcome())
<span class="text-xs font-bold text-slate-300 dark:text-slate-700 tracking-widest">--</span>
@else
@php
$deliveryStatusMap = [
0 => ['label' => __('Dispense Failed'), 'color' => 'rose'],
@ -460,6 +469,7 @@
$ds = $deliveryStatusMap[$order->delivery_status] ?? ['label' => __('Unknown'), 'color' => 'slate'];
@endphp
<x-status-badge :color="$ds['color']" :label="$ds['label']" size="xs" />
@endif
</div>
</div>

View File

@ -0,0 +1,151 @@
<?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());
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace Tests\Feature\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\Transaction\DispenseRecord;
use App\Models\Transaction\Order;
use App\Services\Transaction\TransactionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* 真實庫存扣減的冪等驗證 —— MySQLmachine_stock_movements.type enum MySQL-only DDL
* sqlite :memory: 無法驗)。執行:
*
* ./vendor/bin/sail test -c phpunit.mysql.xml --filter FinalizeStockMysqlTest
*
* 在預設 sqlite 套件中自動 skip,不影響主套件綠燈。
*/
class FinalizeStockMysqlTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private TransactionService $service;
protected function setUp(): void
{
parent::setUp();
if (DB::connection()->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());
}
}