[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:
parent
ee47f65523
commit
34f1e823f0
@ -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'])) {
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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",
|
||||
|
||||
@ -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": "商品已成功更新",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user