[FIX] 修正庫存異動類型缺失與統一交易扣庫邏輯

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 (貨道有物) 的翻譯。
This commit is contained in:
sky121113 2026-05-11 17:13:41 +08:00
parent ee47f65523
commit 34f1e823f0
7 changed files with 142 additions and 95 deletions

View File

@ -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'])) {

View File

@ -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,
]);
}
}

View File

@ -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.
*

View File

@ -13,9 +13,18 @@ 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).
*/
@ -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,
@ -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).
*/
@ -172,13 +229,13 @@ class TransactionService
// 冪等性檢查:如果 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) {
@ -223,7 +280,7 @@ class TransactionService
// 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;
});
}

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('machine_stock_movements', function (Blueprint $table) {
// 在 MySQL 中,更新 ENUM 建議使用 DB::statement 以確保相容性
DB::statement("ALTER TABLE machine_stock_movements MODIFY COLUMN type ENUM('replenishment', 'pickup', 'remote_dispense', 'adjustment', 'rollback', 'sale', 'decommission') NOT NULL");
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machine_stock_movements', function (Blueprint $table) {
DB::statement("ALTER TABLE machine_stock_movements MODIFY COLUMN type ENUM('replenishment', 'pickup', 'remote_dispense', 'adjustment', 'rollback') NOT NULL");
});
}
};

View File

@ -1103,6 +1103,7 @@
"Product Status": "商品狀態",
"Product created successfully": "Product created successfully",
"Product deleted successfully": "Product deleted successfully",
"Product detected (0205)": "Product detected (0205)",
"Product empty": "Product empty",
"Product status updated to :status": "Product status updated to :status",
"Product updated successfully": "Product updated successfully",

View File

@ -1214,6 +1214,7 @@
"Product Status": "商品狀態",
"Product created successfully": "商品已成功建立",
"Product deleted successfully": "商品已成功刪除",
"Product detected (0205)": "貨道有物 (0205)",
"Product empty": "貨道缺貨 (PDT_EMPTY)",
"Product status updated to :status": "商品狀態已更新為 :status",
"Product updated successfully": "商品已成功更新",