diff --git a/app/Http/Controllers/Admin/SalesController.php b/app/Http/Controllers/Admin/SalesController.php
index 756d71a..5f41984 100644
--- a/app/Http/Controllers/Admin/SalesController.php
+++ b/app/Http/Controllers/Admin/SalesController.php
@@ -935,6 +935,8 @@ class SalesController extends Controller
'pending' => __('Pending'),
'paid' => __('Paid'),
'completed' => __('Completed'),
+ 'failed' => __('Failed'),
+ 'abandoned' => __('Unpaid'),
'cancelled' => __('Cancelled'),
'refunded' => __('Refunded'),
];
diff --git a/app/Models/Transaction/Order.php b/app/Models/Transaction/Order.php
index 2e98628..5f6ccbb 100644
--- a/app/Models/Transaction/Order.php
+++ b/app/Models/Transaction/Order.php
@@ -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');
}
}
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/lang/en.json b/lang/en.json
index 4fa8aa9..ef124a7 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -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",
diff --git a/lang/ja.json b/lang/ja.json
index 6d150b6..eb86d99 100644
--- a/lang/ja.json
+++ b/lang/ja.json
@@ -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": "アプリ更新",
diff --git a/lang/zh_TW.json b/lang/zh_TW.json
index 4c8880d..8f06c72 100644
--- a/lang/zh_TW.json
+++ b/lang/zh_TW.json
@@ -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",
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 @@
+
+
+
{{ __('Dispense Status') }}
{{ __('Dispense Status') }}
+ {{-- 非付款成功(未完成/支付失敗/未支付)的單從未出貨,出貨狀態無意義 → 顯示 '--' --}} + @if(! $order->hasDeliveryOutcome()) + -- + @else @php $deliveryStatusMap = [ 0 => ['label' => __('Dispense Failed'), 'color' => 'rose'], @@ -460,6 +469,7 @@ $ds = $deliveryStatusMap[$order->delivery_status] ?? ['label' => __('Unknown'), 'color' => 'slate']; @endphp