star-cloud/app/Services/Transaction/TransactionService.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

565 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Transaction;
use App\Models\Transaction\Order;
use App\Models\Transaction\OrderItem;
use App\Models\Transaction\Invoice;
use App\Models\Transaction\DispenseRecord;
use Illuminate\Support\Facades\DB;
use App\Models\Machine\Machine;
use App\Models\StaffCardLog;
use App\Models\Transaction\PickupCode;
use App\Models\Transaction\PickupCodeLog;
use App\Models\Transaction\PassCodeLog;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use Illuminate\Support\Facades\Log;
use App\Services\Machine\MachineService;
class TransactionService
{
protected $machineService;
public function __construct(MachineService $machineService)
{
$this->machineService = $machineService;
}
/**
* Process a new transaction (B600).
*/
public function processTransaction(array $data): Order
{
$flowId = $data['flow_id'] ?? null;
// 交易生命週期狀態payload 沒帶就預設 completed。
// → main(晟崴/中國醫)的成功 finalize 不帶 status仍得到 'completed',行為逐字不變。
$incomingStatus = $data['status'] ?? Order::STATUS_COMPLETED;
if ($flowId) {
$existingOrder = Order::withoutGlobalScopes()
->where('flow_id', $flowId)
->first();
if ($existingOrder) {
// 鐵律二:終態(completed/failed/abandoned)一律冪等回傳,不可覆蓋。
// main 的單永遠是 completed → 等同原本「flow_id 已存在直接 return」的行為
// 重送/redelivery 也不會重複建立、不會重複扣庫存。
if (in_array($existingOrder->status, Order::TERMINAL_STATUSES, true)) {
return $existingOrder;
}
// 鐵律三:只有「既有單為 pending」才允許狀態轉移pending → completed/failed/abandoned
// 唯有「先送過 pending」的機台(本分支、flag on)會走到這main 從不產生 pending → 不可達。
return $this->transitionOrder($existingOrder, $data, $incomingStatus);
}
}
return DB::transaction(function () use ($data, $incomingStatus) {
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
$paymentStatus = (int) ($data['payment_status'] ?? Order::PAYMENT_STATUS_SUCCESS);
// Create Order
$order = Order::create([
'company_id' => $machine->company_id,
'flow_id' => $data['flow_id'] ?? null,
'order_no' => $data['order_no'] ?? $this->generateOrderNo(),
'machine_id' => $machine->id,
'member_id' => $data['member_id'] ?? null,
'total_amount' => $data['total_amount'],
'discount_amount' => $data['discount_amount'] ?? 0,
'pay_amount' => $data['pay_amount'],
'change_amount' => $data['change_amount'] ?? 0,
'points_used' => $data['points_used'] ?? 0,
'original_amount' => $data['original_amount'] ?? $data['total_amount'],
'payment_type' => (int) ($data['payment_type'] ?? 0),
'payment_status' => $paymentStatus,
'payment_request' => $data['payment_request'] ?? null,
'payment_response' => $data['payment_response'] ?? null,
'member_barcode' => $data['member_barcode'] ?? null,
'code_id' => $data['code_id'] ?? null,
'welcome_gift_id' => $data['welcome_gift_id'] ?? null,
'invoice_info' => $data['invoice_info'] ?? null,
'machine_time' => $data['machine_time'] ?? now(),
// 付款成功才有 payment_atpending/failed 無實際付款時間。
// main = completed + payment_status=1 → now(),與原本一致。
'payment_at' => $paymentStatus === Order::PAYMENT_STATUS_SUCCESS ? now() : null,
'status' => $incomingStatus,
'metadata' => $data['metadata'] ?? null,
'delivery_status' => (int) ($data['delivery_status'] ?? Order::DELIVERY_STATUS_SUCCESS),
]);
$this->createOrderItems($order, $data['items'] ?? []);
return $order;
});
}
/**
* pending 單的狀態轉移pending → completed/failed/abandoned
* 只更新付款結果相關欄位;商品明細在 pending 階段已建立,不重複建立(避免品項翻倍)。
*/
protected function transitionOrder(Order $order, array $data, string $newStatus): Order
{
return DB::transaction(function () use ($order, $data, $newStatus) {
$paymentStatus = isset($data['payment_status'])
? (int) $data['payment_status']
: (int) $order->payment_status;
$order->update([
'status' => $newStatus,
'payment_status' => $paymentStatus,
// payment_type 以本次上報為準:例如手機支付 pending 階段為 4(路由鍵)
// 成功 finalize 覆寫為 10(手機支付),轉移時需一併更新。
'payment_type' => isset($data['payment_type'])
? (int) $data['payment_type']
: $order->payment_type,
'payment_request' => $data['payment_request'] ?? $order->payment_request,
'payment_response' => $data['payment_response'] ?? $order->payment_response,
'pay_amount' => $data['pay_amount'] ?? $order->pay_amount,
'change_amount' => $data['change_amount'] ?? $order->change_amount,
'points_used' => $data['points_used'] ?? $order->points_used,
'invoice_info' => $data['invoice_info'] ?? $order->invoice_info,
'delivery_status' => isset($data['delivery_status'])
? (int) $data['delivery_status']
: $order->delivery_status,
'payment_at' => $paymentStatus === Order::PAYMENT_STATUS_SUCCESS
? ($order->payment_at ?? now())
: $order->payment_at,
]);
// pending 階段若未帶明細,補建一次(防禦)。
if ($order->items()->count() === 0 && !empty($data['items'])) {
$this->createOrderItems($order, $data['items']);
}
return $order->fresh();
});
}
/** 建立訂單品項(沒帶名稱時回資料庫補抓)。 */
protected function createOrderItems(Order $order, array $items): void
{
foreach ($items as $item) {
$productName = $item['product_name'] ?? null;
// 如果沒傳名稱,嘗試從資料庫抓取
if (empty($productName)) {
$product = \App\Models\Product\Product::find($item['product_id']);
$productName = $product?->name ?? 'Unknown';
}
$order->items()->create([
'product_id' => $item['product_id'],
'product_name' => $productName,
'barcode' => $item['barcode'] ?? null,
'price' => $item['price'],
'quantity' => $item['quantity'],
'subtotal' => $item['price'] * $item['quantity'],
]);
}
}
/**
* Generate a unique order number.
*/
protected function generateOrderNo(): string
{
return 'ORD-' . now()->format('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3)));
}
/**
* Record Invoice (B601).
*/
public function recordInvoice(array $data): Invoice
{
return DB::transaction(function () use ($data) {
$machine = Machine::withoutGlobalScopes()->where('serial_no', $data['serial_no'])->firstOrFail();
$order = null;
if (!empty($data['flow_id'])) {
$order = Order::withoutGlobalScopes()->where('flow_id', $data['flow_id'])->first();
}
// 處理額外的發票資訊 (business_tax_id, carrier_type)
$metadata = $data['metadata'] ?? [];
if (isset($data['business_tax_id']))
$metadata['business_tax_id'] = $data['business_tax_id'];
if (isset($data['carrier_type']))
$metadata['carrier_type'] = $data['carrier_type'];
$invoiceNo = $data['invoice_no'] ?? null;
$rtnCode = $data['rtn_code'] ?? null;
// 狀態:優先採用 App 已判定的 status新 finalize 流程一定帶 status=pending否則才推導。
// 此推導分支目前僅 MQTT 'invoice' 指令(ProcessInvoice)會走App 暫未使用,屬防禦性。
// 重點:有發票號就代表已開立,除非「明確」收到失敗碼才算 failed
//(部分上報格式只帶 invoice_no、不帶 rtn_code不可因缺 rtn_code 就誤判失敗)。
// 綠界 RtnCode == "1" 為成功;其餘非空值才視為明確失敗。
$status = $data['status'] ?? null;
if (empty($status)) {
$explicitFailure = !empty($rtnCode) && (string) $rtnCode !== '1';
if (!empty($invoiceNo)) {
$status = $explicitFailure ? 'failed' : 'issued';
} else {
// 無發票號明確失敗→failed否則待開立/待查證→pending
$status = $explicitFailure ? 'failed' : 'pending';
}
}
// 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($data['flow_id'], 0, 30);
}
$attributes = [
'company_id' => $machine->company_id,
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
'machine_id' => $machine->id,
'invoice_no' => $invoiceNo,
'status' => $status,
'relate_number' => $relateNumber,
'amount' => $data['amount'] ?? 0,
'carrier_id' => $data['carrier_id'] ?? null,
'invoice_date' => $data['invoice_date'] ?? null,
'machine_time' => $data['machine_time'] ?? null,
'random_number' => $data['random_number'] ?? ($data['random_no'] ?? null),
'love_code' => $data['love_code'] ?? null,
'rtn_code' => $rtnCode,
'rtn_msg' => $data['rtn_msg'] ?? null,
'metadata' => $metadata,
];
// 去重:同一 flow_id一張訂單一張發票以 updateOrCreate 收斂,
// 避免 finalize 重送 / 補開回寫產生重複發票列。
if (!empty($data['flow_id'])) {
// 終態守衛(防禦縱深):已開立/已作廢的發票為終態,不得被後續寫入覆蓋,
// 避免任何重送(含 ProcessInvoice/MQTT invoice 指令)把 issued 打回 pending、清掉發票號。
$existing = Invoice::withoutGlobalScopes()->where('flow_id', $data['flow_id'])->first();
if ($existing && in_array($existing->status, [Invoice::STATUS_ISSUED, Invoice::STATUS_VOID], true)) {
return $existing;
}
return Invoice::withoutGlobalScopes()->updateOrCreate(
['flow_id' => $data['flow_id']],
$attributes
);
}
return Invoice::create(array_merge($attributes, ['flow_id' => null]));
});
}
/**
* Record dispense result (B602).
*/
public function recordDispense(array $data): DispenseRecord
{
return DB::transaction(function () use ($data) {
// Use withoutGlobalScopes for background job safety
$machine = Machine::withoutGlobalScopes()->where('serial_no', $data['serial_no'])->firstOrFail();
$order = null;
if (!empty($data['flow_id'])) {
$order = Order::withoutGlobalScopes()->where('flow_id', $data['flow_id'])->first();
}
// Find slot with robust matching (handle "1" vs "01")
$slotNo = $data['slot_no'] ?? null;
$slot = null;
if ($slotNo) {
$slot = $machine->slots()->where('slot_no', (string) $slotNo)->first();
if (!$slot) {
$normalized = ltrim((string) $slotNo, '0');
if ($normalized === '')
$normalized = '0';
$slot = $machine->slots()->get()->first(function ($s) use ($normalized) {
$sn = ltrim($s->slot_no, '0');
if ($sn === '')
$sn = '0';
return $sn === $normalized;
});
}
}
$record = DispenseRecord::create([
'company_id' => $machine->company_id,
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
'flow_id' => $data['flow_id'] ?? null,
'machine_id' => $machine->id,
'slot_no' => $slotNo ?? 'unknown',
'product_id' => ($data['product_id'] ?? 0) > 0 ? $data['product_id'] : ($slot ? $slot->product_id : null),
'amount' => $data['amount'] ?? 0,
'remaining_stock' => $data['remaining_stock'] ?? null,
'dispense_status' => $data['dispense_status'] ?? 0,
'member_barcode' => $data['member_barcode'] ?? ($order?->member_barcode ?? null),
'machine_time' => $data['machine_time'] ?? now(),
'points_used' => $data['points_used'] ?? 0,
]);
// 執行實體庫存扣除 (真實交易化)
if ((int) ($data['dispense_status'] ?? 0) === 1) {
if ($slot) {
$slot->decrement('stock');
$type = \App\Models\Machine\MachineStockMovement::TYPE_SALE;
$this->machineService->recordStockMovement(
$slot,
-1,
$type,
$order ?? $record,
$order ? "Sale Order: #{$order->order_no}" : "Dispense Record: #{$record->id}",
['order_id' => $order?->id, 'dispense_id' => $record->id]
);
} else {
// Log warning if slot matching fails to help diagnostic
\Log::warning("Dispense record created but slot not found for decrement", [
'machine_id' => $machine->id,
'slot_no' => $slotNo
]);
}
}
return $record;
});
}
/**
* Finalize a complete transaction (Unified B600/B601/B602).
*/
public function finalizeTransaction(array $data): Order
{
// 優先從 Root 讀取,再從 order 內讀取
$rawFlowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null);
if (empty($rawFlowId)) {
throw new \Exception("Flow ID is required for finalization");
}
// 跨機台防撞號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,
'status' => $existingOrder->status,
]);
return $existingOrder;
}
// 1. Process Order (B600)
$orderData = $data['order'];
$orderData['serial_no'] = $serialNo;
// 確保提取 payment_type 與 flow_id (可能在 root 或 order 內)
$orderData['flow_id'] = $flowId;
if (!isset($orderData['delivery_status']) && !empty($data['dispense'])) {
$orderData['delivery_status'] = $this->resolveDeliveryStatusFromDispense($data['dispense']);
}
if (!isset($orderData['payment_type']) && isset($data['payment_type'])) {
$orderData['payment_type'] = $data['payment_type'];
}
$order = $this->processTransaction($orderData);
// 2. Record Invoice — 後台開立版finalize 帶 invoice 輸入(status=pending)時建 pending 發票,
// commit 後派 IssueInvoiceJob 去綠界開立(不在交易內等 ECPay避免長交易
// 開立條件:機台「電子發票開關」開啟 AND 上報帶 invoice兩者皆成立才開立。
// 機台關閉電子發票時略過開立(不建發票、不送綠界),僅留 warning log 供日後追查。
if (!empty($data['invoice']) && $order->machine?->tax_invoice_enabled) {
$invoiceData = $data['invoice'];
$invoiceData['serial_no'] = $serialNo;
$invoiceData['flow_id'] = $order->flow_id;
$invoiceData['order_id'] = $order->id;
$invoice = $this->recordInvoice($invoiceData);
// 待開立 → commit 後非同步開立(已開/失敗則不重複觸發)
if ($invoice->status === Invoice::STATUS_PENDING) {
DB::afterCommit(function () use ($invoice) {
\App\Jobs\Transaction\IssueInvoiceJob::dispatch($invoice->id);
});
}
} elseif (!empty($data['invoice'])) {
// 上報帶了 invoice但機台「電子發票開關」為關閉 → 略過開立,留軌跡供追查。
Log::warning('機台已關閉電子發票,略過開立', [
'machine_id' => $order->machine_id,
'flow_id' => $order->flow_id,
'order_id' => $order->id,
]);
}
// 3. Record Dispense Results (B602) - Optional/Multiple
if (!empty($data['dispense'])) {
$dispenseList = isset($data['dispense'][0]) ? $data['dispense'] : [$data['dispense']];
foreach ($dispenseList as $dispenseItem) {
$dispenseItem['serial_no'] = $serialNo;
$dispenseItem['flow_id'] = $order->flow_id;
$dispenseItem['order_id'] = $order->id;
// 自動繼承:如果出貨紀錄沒帶 Barcode就用訂單的
if (empty($dispenseItem['member_barcode']) && !empty($order->member_barcode)) {
$dispenseItem['member_barcode'] = $order->member_barcode;
}
$this->recordDispense($dispenseItem);
}
}
// 4. 閉環勾稽 (Closed Loop Check)
$codeId = $data['order']['code_id'] ?? ($data['code_id'] ?? null);
$paymentType = (int) $order->payment_type;
if ($codeId) {
switch ($paymentType) {
case 41: // 員工卡 (Staff Card)
// 建立員工卡核銷日誌
StaffCardLog::create([
'company_id' => $order->company_id,
'machine_id' => $order->machine_id,
'staff_card_id' => $codeId,
'payment_type' => 41,
'code_id' => $codeId,
'order_id' => $order->id,
'action' => 'consume',
'remark' => "MQTT 交易完成自動核銷,訂單: #{$order->id}"
]);
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;
$isUsedUp = $newCount >= $pickupCode->usage_limit;
$pickupCode->update([
'order_id' => $order->id,
'usage_count' => $newCount,
'status' => $isUsedUp ? 'used' : 'active',
'used_at' => $isUsedUp ? now() : $pickupCode->used_at
]);
// 建立核銷日誌 (閉環最後一哩路)
PickupCodeLog::create([
'company_id' => $order->company_id,
'machine_id' => $order->machine_id,
'pickup_code_id' => $pickupCode->id,
'order_id' => $order->id,
'action' => $isUsedUp ? 'used' : 'consume',
'remark' => "MQTT 交易完成自動核銷 (" . ($newCount) . "/" . $pickupCode->usage_limit . "),訂單: #{$order->id}",
'raw_data' => [
'slot' => $pickupCode->slot_no,
'code' => $pickupCode->code,
'count' => $newCount,
'limit' => $pickupCode->usage_limit
]
]);
}
break;
case 5: // 通行碼 (Pass Code)
// 建立通行碼核銷日誌
PassCodeLog::create([
'company_id' => $order->company_id,
'machine_id' => $order->machine_id,
'pass_code_id' => $codeId,
'order_id' => $order->id,
'action' => 'completed',
'remark' => "MQTT 交易完成自動紀錄取貨,訂單: #{$order->id}"
]);
break;
}
}
// 處理來店禮 (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;
$isUsedUp = ($welcomeGift->usage_type === 'once' && $newCount >= ($welcomeGift->usage_limit ?? 1));
$welcomeGift->update([
'usage_count' => $newCount,
'status' => $isUsedUp ? 'used' : 'active',
]);
WelcomeGiftLog::create([
'company_id' => $order->company_id,
'machine_id' => $order->machine_id,
'welcome_gift_id' => $welcomeGift->id,
'order_id' => $order->id,
'action' => 'consume',
'remark' => "MQTT 交易完成自動核銷,訂單: #{$order->id}",
'raw_data' => [
'code' => $welcomeGift->code,
'count' => $newCount,
'type' => $welcomeGift->usage_type
]
]);
}
}
return $order;
});
}
/**
* Resolve order-level delivery status from item-level dispense results.
*/
protected function resolveDeliveryStatusFromDispense(array $dispense): int
{
$dispenseList = isset($dispense[0]) ? $dispense : [$dispense];
$statuses = collect($dispenseList)
->filter(fn ($item) => is_array($item) && array_key_exists('dispense_status', $item))
->map(fn ($item) => (int) $item['dispense_status']);
if ($statuses->isEmpty()) {
return Order::DELIVERY_STATUS_SUCCESS;
}
$successCount = $statuses
->filter(fn ($status) => $status === Order::DELIVERY_STATUS_SUCCESS)
->count();
if ($successCount === $statuses->count()) {
return Order::DELIVERY_STATUS_SUCCESS;
}
if ($successCount === 0) {
return Order::DELIVERY_STATUS_FAILED;
}
return Order::DELIVERY_STATUS_PARTIAL;
}
}