From 34f1e823f0f85aa995ad6abdfa38dfb6fb3d9e72 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Mon, 11 May 2026 17:13:41 +0800 Subject: [PATCH] =?UTF-8?q?[FIX]=20=E4=BF=AE=E6=AD=A3=E5=BA=AB=E5=AD=98?= =?UTF-8?q?=E7=95=B0=E5=8B=95=E9=A1=9E=E5=9E=8B=E7=BC=BA=E5=A4=B1=E8=88=87?= =?UTF-8?q?=E7=B5=B1=E4=B8=80=E4=BA=A4=E6=98=93=E6=89=A3=E5=BA=AB=E9=82=8F?= =?UTF-8?q?=E8=BC=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 補全 machine_stock_movements 資料表 type 欄位缺失的 sale 與 decommission 列舉值,修復 SQL 寫入失敗問題。 2. 重構 TransactionService:移除過時的 remote_dispense 判斷,將所有經由交易流程(含支付代碼 100)的出貨統一記錄為 sale 異動。 3. 更新 MachineService:將 B009 貨道同步時的移除動作類型由 adjustment 改為正確的 decommission。 4. 語系更新:於 zh_TW.json 與 en.json 補全硬體狀態碼 0205 (貨道有物) 的翻譯。 --- app/Jobs/Machine/ProcessCommandAck.php | 64 ++-------- app/Models/Machine/MachineStockMovement.php | 2 + app/Services/Machine/MachineService.php | 30 ++--- .../Transaction/TransactionService.php | 109 ++++++++++++++---- ...types_to_machine_stock_movements_table.php | 30 +++++ lang/en.json | 1 + lang/zh_TW.json | 1 + 7 files changed, 142 insertions(+), 95 deletions(-) create mode 100644 database/migrations/2026_05_11_163543_add_missing_types_to_machine_stock_movements_table.php diff --git a/app/Jobs/Machine/ProcessCommandAck.php b/app/Jobs/Machine/ProcessCommandAck.php index ca351e2..919fd69 100644 --- a/app/Jobs/Machine/ProcessCommandAck.php +++ b/app/Jobs/Machine/ProcessCommandAck.php @@ -90,60 +90,20 @@ class ProcessCommandAck implements ShouldQueue $machine = $command->machine; if ($command->command_type === 'dispense') { - $slotNo = $command->payload['slot_no'] ?? null; - $slot = $machine->slots()->where('slot_no', $slotNo)->first(); + // 遠端出貨指令僅紀錄狀態,不再執行庫存預扣或回滾。 + // 真正的庫存異動將由機台發送的 Transaction Finalize (結案) 流程處理。 + $msgKey = ($status === 'success') ? "Remote dispense successful for slot :slot" : "Remote dispense failed for slot :slot"; + $level = ($status === 'success') ? 'info' : 'error'; - if ($slot) { - if ($status === 'success') { - // 若出貨成功,且 APP 回報了最新的庫存值,則以 APP 為準進行校準 - // 否則維持預扣後的狀態 (不需要額外動作,因為下發時已扣除) - if ($stockReported !== null) { - $slot->update(['stock' => (int)$stockReported]); - $payloadUpdates['new_stock'] = (int)$stockReported; - } - - ProcessStateLog::dispatch( - $machine->id, - $machine->company_id, - "Remote dispense successful for slot :slot", - 'info', - ['slot' => $slotNo] - ); - } else { - // 若出貨失敗,執行回滾 (Rollback):將預扣的庫存加回去 - $oldStock = $command->payload['old_stock'] ?? $slot->stock; - $slot->update(['stock' => $oldStock]); - - $payloadUpdates['rolled_back'] = true; - $payloadUpdates['new_stock'] = $oldStock; - - // 記錄 rollback 庫存異動流水帳 - $slot->refresh(); - $machineService->recordStockMovement( - $slot, - +1, - MachineStockMovement::TYPE_ROLLBACK, - $command, - "遠端出貨失敗 Rollback,指令 ID: {$command->id},結果碼: {$resultCode}" - ); - - Log::warning('MQTT Dispense failed, rolled back inventory', [ - 'serial_no' => $this->serialNo, - 'slot_no' => $slotNo, - 'rolled_back_to' => $oldStock - ]); - - // 記錄到機台日誌 - ProcessStateLog::dispatch( - $machine->id, - $machine->company_id, - "Remote dispense failed for slot :slot", - 'error', - ['slot' => $slotNo] - ); - } - } + ProcessStateLog::dispatch( + $machine->id, + $machine->company_id, + $msgKey, + $level, + ['slot' => $command->payload['slot_no'] ?? 'N/A'] + ); } + elseif ($command->command_type === 'reload_stock') { // 若同步失敗,自動 Rollback 回滾庫存 if ($status === 'failed' && isset($command->payload['slot_no'], $command->payload['old'])) { diff --git a/app/Models/Machine/MachineStockMovement.php b/app/Models/Machine/MachineStockMovement.php index a96f203..9fb7450 100644 --- a/app/Models/Machine/MachineStockMovement.php +++ b/app/Models/Machine/MachineStockMovement.php @@ -62,6 +62,7 @@ class MachineStockMovement extends Model const TYPE_REPLENISHMENT = 'replenishment'; // 補貨單完成 const TYPE_PICKUP = 'pickup'; // 取貨碼核銷 const TYPE_REMOTE_DISPENSE = 'remote_dispense'; // 遠端出貨 (B055 樂觀扣除) + const TYPE_SALE = 'sale'; // 一般訂單銷售 const TYPE_ADJUSTMENT = 'adjustment'; // B009 回報 / 後台手動修改 const TYPE_ROLLBACK = 'rollback'; // B055 出貨失敗回退 const TYPE_DECOMMISSION = 'decommission'; // B009 全量同步時移除不在清單的貨道 @@ -112,6 +113,7 @@ class MachineStockMovement extends Model return in_array($this->type, [ self::TYPE_PICKUP, self::TYPE_REMOTE_DISPENSE, + self::TYPE_SALE, ]); } } diff --git a/app/Services/Machine/MachineService.php b/app/Services/Machine/MachineService.php index 03c43dc..fae1270 100644 --- a/app/Services/Machine/MachineService.php +++ b/app/Services/Machine/MachineService.php @@ -51,6 +51,7 @@ class MachineService '0201' => ['label' => 'Slot normal', 'level' => 'info'], '0202' => ['label' => 'Product empty', 'level' => 'warning'], '0203' => ['label' => 'Slot empty', 'level' => 'warning'], + '0205' => ['label' => 'Product detected (0205)', 'level' => 'warning'], '0206' => ['label' => 'Slot not closed', 'level' => 'warning'], '0207' => ['label' => 'Slot motor error (0207)', 'level' => 'error'], '0208' => ['label' => 'Slot motor error (0208)', 'level' => 'error'], @@ -290,7 +291,7 @@ class MachineService $this->recordStockMovement( $orphan, -$oldStock, - MachineStockMovement::TYPE_ADJUSTMENT, // 使用 adjustment 以確保相容舊版 Enum + MachineStockMovement::TYPE_DECOMMISSION, null, "movement.note.slot_decommissioned_by_b009", ['old' => $oldStock, 'new' => 0, 'slot_no' => $orphan->slot_no] @@ -397,7 +398,7 @@ class MachineService } /** - * 遠端出貨指令下發 (含樂觀扣庫存與鎖定) + * 遠端出貨指令下發 (指令模式,不再預扣庫存) */ public function dispatchDispense(Machine $machine, string $slotNo, ?int $userId = null): RemoteCommand { @@ -419,13 +420,7 @@ class MachineService throw new \Exception(__('Out of stock.')); } - $oldStock = $slot->stock; - $newStock = $oldStock - 1; - - // 1. 執行樂觀扣除 - $slot->update(['stock' => $newStock]); - - // 2. 建立指令紀錄 + // 1. 建立指令紀錄 (不在此處扣庫存,等候交易結案上報) $command = RemoteCommand::create([ 'machine_id' => $machine->id, 'user_id' => $userId, @@ -433,23 +428,11 @@ class MachineService 'status' => 'pending', 'payload' => [ 'slot_no' => (string)$slotNo, - 'old_stock' => $oldStock, - 'new_stock' => $newStock, + 'current_stock' => $slot->stock, ] ]); - // 3. 寫入庫存異動流水帳(remote_dispense,樂觀扣除) - $slot->refresh(); - $this->recordStockMovement( - $slot, - -1, - MachineStockMovement::TYPE_REMOTE_DISPENSE, - $command, - "movement.note.remote_dispense_queued", - ['slot_no' => $slotNo, 'id' => $command->id] - ); - - // 4. 推播 MQTT + // 2. 推播 MQTT app(\App\Services\Machine\MqttService::class)->pushCommand( $machine->serial_no, 'dispense', @@ -461,6 +444,7 @@ class MachineService }); } + /** * B013: Record machine hardware error/status log with auto-translation. * diff --git a/app/Services/Transaction/TransactionService.php b/app/Services/Transaction/TransactionService.php index 8772d89..23b38b3 100644 --- a/app/Services/Transaction/TransactionService.php +++ b/app/Services/Transaction/TransactionService.php @@ -13,16 +13,25 @@ use App\Models\Transaction\PickupCode; use App\Models\Transaction\PickupCodeLog; use App\Models\Transaction\PassCodeLog; 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; - + if ($flowId) { $existingOrder = Order::withoutGlobalScopes() ->where('flow_id', $flowId) @@ -34,7 +43,7 @@ class TransactionService return DB::transaction(function () use ($data) { $machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail(); - + // Create Order $order = Order::create([ 'company_id' => $machine->company_id, @@ -48,7 +57,7 @@ class TransactionService '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_type' => (int) ($data['payment_type'] ?? 0), 'payment_status' => $data['payment_status'] ?? 1, 'payment_request' => $data['payment_request'] ?? null, 'payment_response' => $data['payment_response'] ?? null, @@ -65,7 +74,7 @@ class TransactionService if (!empty($data['items'])) { foreach ($data['items'] as $item) { $productName = $item['product_name'] ?? null; - + // 如果沒傳名稱,嘗試從資料庫抓取 if (empty($productName)) { $product = \App\Models\Product\Product::find($item['product_id']); @@ -102,7 +111,7 @@ class TransactionService { return DB::transaction(function () use ($data) { $machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail(); - + $order = null; if (!empty($data['flow_id'])) { $order = Order::where('flow_id', $data['flow_id'])->first(); @@ -110,8 +119,10 @@ class TransactionService // 處理額外的發票資訊 (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']; + 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']; return Invoice::create([ 'company_id' => $machine->company_id, @@ -138,20 +149,39 @@ class TransactionService public function recordDispense(array $data): DispenseRecord { return DB::transaction(function () use ($data) { - $machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail(); - + // 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::where('flow_id', $data['flow_id'])->first(); + $order = Order::withoutGlobalScopes()->where('flow_id', $data['flow_id'])->first(); } - return DispenseRecord::create([ + // 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' => $data['slot_no'] ?? 'unknown', - 'product_id' => $data['product_id'] ?? null, + '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, @@ -159,9 +189,36 @@ class TransactionService '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). */ @@ -169,16 +226,16 @@ class TransactionService { // 優先從 Root 讀取,再從 order 內讀取 $flowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null); - + // 冪等性檢查:如果 flow_id 已經存在,直接回傳既有訂單 if ($flowId) { - $existingOrder = Order::withoutGlobalScopes() - ->where('flow_id', $flowId) - ->first(); + $existingOrder = Order::withoutGlobalScopes()->where('flow_id', $flowId)->first(); if ($existingOrder) { Log::info("Transaction already finalized, returning existing order", ['flow_id' => $flowId]); return $existingOrder; } + } else { + throw new \Exception("Flow ID is required for finalization"); } return DB::transaction(function () use ($data, $flowId) { @@ -211,19 +268,19 @@ class TransactionService $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; + $paymentType = (int) $order->payment_type; if ($codeId) { switch ($paymentType) { @@ -286,9 +343,21 @@ class TransactionService 'remark' => "MQTT 交易完成自動紀錄取貨,訂單: #{$order->id}" ]); break; + + case 100: // 遠端出貨 (Remote Admin) + $remoteCommand = \App\Models\Machine\RemoteCommand::find($codeId); + if ($remoteCommand) { + $remoteCommand->update([ + 'status' => 'success', + 'executed_at' => $order->machine_time ?? now(), + 'note' => "Transaction Finalized: #{$order->order_no}" + ]); + } + break; } } + return $order; }); } diff --git a/database/migrations/2026_05_11_163543_add_missing_types_to_machine_stock_movements_table.php b/database/migrations/2026_05_11_163543_add_missing_types_to_machine_stock_movements_table.php new file mode 100644 index 0000000..a3e8cf9 --- /dev/null +++ b/database/migrations/2026_05_11_163543_add_missing_types_to_machine_stock_movements_table.php @@ -0,0 +1,30 @@ +