[FEAT] 完善遠端出貨與庫存異動的併發控制與視覺回饋
1. 修正 RemoteController 中的 MachineService 注入問題,解決遠端出貨指令失效的 500 錯誤。 2. 在 MachineService 實作貨道鎖定機制 (lockSlot/unlockSlot),防止重複扣庫存導致的數據不一致。 3. 採用樂觀扣除模式,發送指令時即更新預扣庫存,並在 ProcessCommandAck 實作失敗自動 Rollback。 4. 優化 UI 互動:在操作紀錄詳情顯示庫存變化 (如 6 -> 5),且針對執行失敗的指令加上刪除線樣式。 5. 完成指令詳情、操作紀錄與庫存歷史的語系對照表同步 (zh_TW, en, ja)。
This commit is contained in:
parent
a8b150cadc
commit
9114269a7a
@ -21,7 +21,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
- **Header**:`Authorization: Bearer <user_token>`。
|
||||
|
||||
### 2.2 機台通訊認證 (Machine Authentication)
|
||||
- **適用 API**:B012, B013, B600 等後續通訊。
|
||||
- **適用 API**:B012, B009 等後續通訊。
|
||||
- **方式**:使用機台專屬之 **api_token**。
|
||||
- **Header**:`Authorization: Bearer <api_token>`。
|
||||
|
||||
@ -80,18 +80,14 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
### 3.3 B009: 貨道庫存即時回報 (Supplementary Report)
|
||||
當維修或補貨人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。
|
||||
|
||||
#### B009 權限驗證邏輯 (RBAC Compliance)
|
||||
系統會依據 account 欄位進行三層式權限核查:
|
||||
1. **系統層 (System Admin)**:當 company_id 為 null 時,具備全局管理權限,直接放行。
|
||||
2. **公司層 (Company Admin)**:當 is_admin 為 true 時,檢查機台的 company_id 是否與該帳號一致。
|
||||
3. **人員層 (Operator/User)**:當帳號僅為一般人員時,檢查 machine_user 授權表,確認該帳號有被分配至此機台。
|
||||
|
||||
|
||||
- **URL**: PUT /api/v1/app/products/supplementary/B009
|
||||
- **Request Body:**
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Request Body:** 無 (由 Token 自動識別機台)
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| account | String | 是 | 操作人員帳號 | 0999123456 |
|
||||
| data | Array | 是 | 貨道數據陣列 | [{"tid":"1", "t060v00":"1", "num":"10"}] |
|
||||
|
||||
- **data 陣列內部欄位:**
|
||||
@ -152,24 +148,12 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
### 3.6 B013: [DEPRECATED / REMOVED] 機台故障與異常狀態上報
|
||||
> [!WARNING]
|
||||
> **此 REST API 已廢棄並移除**。機台異常上報已全面遷移至 **MQTT** 即時通訊架構。
|
||||
> 請參閱 [MQTT 通訊規範 - B013 異常上報](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md#32-異常與錯誤上報-b013) 進行串接。
|
||||
> 詳細錯誤代碼請參閱:[MQTT 通訊規範 - B013 硬體代碼對照表](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md#32-異常與錯誤上報-b013)
|
||||
|
||||
### 3.12 B010: [DEPRECATED / REMOVED] 心跳上報與狀態同步
|
||||
> [!WARNING]
|
||||
> **此 REST API 已廢棄並移除**。機台心跳上報與指令下發已全面遷移至 **MQTT** 架構。
|
||||
> 請參閱 [MQTT 通訊規範 - B010 心跳上報](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md#31-心跳上報-b010) 進行串接。
|
||||
|
||||
#### B013 硬體代碼對照表 (由 MachineService 自動翻譯)
|
||||
|
||||
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **0402** | Dispense successful | info | 出貨成功 |
|
||||
| **0403** | Slot jammed | error | **貨道卡貨** (重大異常) |
|
||||
| **0202** | Product empty | warning | 貨道缺貨 |
|
||||
| **0412** | Elevator rise error | error | 昇降機上升異常 |
|
||||
| **0415** | Pickup door error | error | 取貨門異常 |
|
||||
| **5402** | Pickup door not closed | warning | **取貨門未關** (警告) |
|
||||
| **5403** | Elevator failure | error | 昇降系統故障 |
|
||||
> 詳細格式請參閱:[MQTT 通訊規範 - B010 心跳上報](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md#31-心跳上報-b010)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -136,7 +136,19 @@ Mqtt3Client client = MqttClient.builder()
|
||||
"error_code": "0403"
|
||||
}
|
||||
```
|
||||
*(註:`error_code` 應遵循 `MachineService::ERROR_CODE_MAP` 定義,雲端將自動轉換為對應語言標籤。`tid` 為貨道或任務 ID。)*
|
||||
#### B013 硬體代碼對照表 (由 MachineService 自動翻譯)
|
||||
|
||||
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **0402** | Dispense successful | info | 出貨成功 |
|
||||
| **0403** | Slot jammed | error | **貨道卡貨** (重大異常) |
|
||||
| **0202** | Product empty | warning | 貨道缺貨 |
|
||||
| **0412** | Elevator rise error | error | 昇降機上升異常 |
|
||||
| **0415** | Pickup door error | error | 取貨門異常 |
|
||||
| **5402** | Pickup door not closed | warning | **取貨門未關** (警告) |
|
||||
| **5403** | Elevator failure | error | 昇降系統故障 |
|
||||
|
||||
*(註:`error_code` 應遵循此表定義,雲端將根據 `i18n` 自動轉換。`tid` 為貨道或任務 ID。)*
|
||||
|
||||
### 3.3 交易生命週期上報 (Transaction) - `machine/{serial_no}/transaction`
|
||||
|
||||
|
||||
@ -133,7 +133,24 @@ class MachineController extends AdminController
|
||||
*/
|
||||
public function slotsAjax(Machine $machine)
|
||||
{
|
||||
$slots = $machine->slots()->with('product:id,name,image_url')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')->get();
|
||||
$slots = $machine->slots()
|
||||
->with('product:id,name,image_url')
|
||||
->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')
|
||||
->get();
|
||||
|
||||
// 取得該機台目前所有等待中的庫存更新指令 (Pending Commands)
|
||||
$pendingSlotNos = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||||
->where('command_type', 'reload_stock')
|
||||
->where('status', 'pending')
|
||||
->get()
|
||||
->pluck('payload.slot_no')
|
||||
->flatten()
|
||||
->toArray();
|
||||
|
||||
// 將待處理狀態注入貨道物件
|
||||
$slots->each(function ($slot) use ($pendingSlotNos) {
|
||||
$slot->is_pending = in_array((int)$slot->slot_no, $pendingSlotNos);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -154,14 +171,21 @@ class MachineController extends AdminController
|
||||
'batch_no' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
$this->machineService->updateSlot($machine, $validated, auth()->id());
|
||||
try {
|
||||
$this->machineService->updateSlot($machine, $validated, auth()->id());
|
||||
|
||||
session()->flash('success', __('Slot updated successfully.'));
|
||||
session()->flash('success', __('Slot updated successfully.'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Slot updated successfully.')
|
||||
]);
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Slot updated successfully.')
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -7,15 +7,18 @@ use Illuminate\Http\Request;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\RemoteCommand;
|
||||
use App\Services\Machine\MqttService;
|
||||
use App\Services\Machine\MachineService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class RemoteController extends Controller
|
||||
{
|
||||
protected $mqttService;
|
||||
protected $machineService;
|
||||
|
||||
public function __construct(MqttService $mqttService)
|
||||
public function __construct(MqttService $mqttService, MachineService $machineService)
|
||||
{
|
||||
$this->mqttService = $mqttService;
|
||||
$this->machineService = $machineService;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -138,41 +141,47 @@ class RemoteController extends Controller
|
||||
'note' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$payload = [];
|
||||
if ($validated['command_type'] === 'change') {
|
||||
$payload['amount'] = $validated['amount'];
|
||||
} elseif ($validated['command_type'] === 'dispense') {
|
||||
$payload['slot_no'] = $validated['slot_no'];
|
||||
}
|
||||
if ($validated['command_type'] === 'dispense') {
|
||||
$this->machineService->dispatchDispense(
|
||||
Machine::findOrFail($validated['machine_id']),
|
||||
$validated['slot_no'],
|
||||
auth()->id()
|
||||
);
|
||||
} else {
|
||||
$payload = [];
|
||||
if ($validated['command_type'] === 'change') {
|
||||
$payload['amount'] = $validated['amount'];
|
||||
}
|
||||
|
||||
// 指令去重:將同機台、同類型的 pending 指令標記為「已取代」
|
||||
RemoteCommand::where('machine_id', $validated['machine_id'])
|
||||
->where('command_type', $validated['command_type'])
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'superseded',
|
||||
'note' => __('Superseded by new command'),
|
||||
'executed_at' => now(),
|
||||
// 指令去重:將同機台、同類期的 pending 指令標記為「已取代」
|
||||
RemoteCommand::where('machine_id', $validated['machine_id'])
|
||||
->where('command_type', $validated['command_type'])
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'superseded',
|
||||
'note' => __('Superseded by new command'),
|
||||
'executed_at' => now(),
|
||||
]);
|
||||
|
||||
$command = RemoteCommand::create([
|
||||
'machine_id' => $validated['machine_id'],
|
||||
'user_id' => auth()->id(),
|
||||
'command_type' => $validated['command_type'],
|
||||
'payload' => $payload,
|
||||
'status' => 'pending',
|
||||
'note' => $validated['note'] ?? null,
|
||||
]);
|
||||
|
||||
$command = RemoteCommand::create([
|
||||
'machine_id' => $validated['machine_id'],
|
||||
'user_id' => auth()->id(),
|
||||
'command_type' => $validated['command_type'],
|
||||
'payload' => $payload,
|
||||
'status' => 'pending',
|
||||
'note' => $validated['note'] ?? null,
|
||||
]);
|
||||
|
||||
// 【新增】同步推播 MQTT 指令
|
||||
$machine = Machine::find($validated['machine_id']);
|
||||
if ($machine) {
|
||||
$this->mqttService->pushCommand(
|
||||
$machine->serial_no,
|
||||
$command->command_type,
|
||||
$command->payload,
|
||||
$command->id
|
||||
);
|
||||
// 推播 MQTT 指令
|
||||
$machine = Machine::find($validated['machine_id']);
|
||||
if ($machine) {
|
||||
$this->mqttService->pushCommand(
|
||||
$machine->serial_no,
|
||||
$command->command_type,
|
||||
$command->payload,
|
||||
(string) $command->id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
session()->flash('success', __('Command has been queued successfully.'));
|
||||
|
||||
@ -88,26 +88,18 @@ class ProcessCommandAck implements ShouldQueue
|
||||
$machine = $command->machine;
|
||||
|
||||
if ($command->command_type === 'dispense') {
|
||||
// 若成功,連動更新貨道庫存
|
||||
if ($status === 'success' && isset($command->payload['slot_no'])) {
|
||||
$slotNo = $command->payload['slot_no'];
|
||||
$slot = $machine->slots()->where('slot_no', $slotNo)->first();
|
||||
|
||||
if ($slot) {
|
||||
$oldStock = $slot->stock;
|
||||
// 若 APP 回傳的庫存大於 0 則使用回傳值,否則執行扣減
|
||||
$newStock = (int) ($stockReported ?? 0);
|
||||
if ($newStock <= 0 && $oldStock > 0) {
|
||||
$newStock = $oldStock - 1;
|
||||
$slotNo = $command->payload['slot_no'] ?? null;
|
||||
$slot = $machine->slots()->where('slot_no', $slotNo)->first();
|
||||
|
||||
if ($slot) {
|
||||
if ($status === 'success') {
|
||||
// 若出貨成功,且 APP 回報了最新的庫存值,則以 APP 為準進行校準
|
||||
// 否則維持預扣後的狀態 (不需要額外動作,因為下發時已扣除)
|
||||
if ($stockReported !== null) {
|
||||
$slot->update(['stock' => (int)$stockReported]);
|
||||
$payloadUpdates['new_stock'] = (int)$stockReported;
|
||||
}
|
||||
|
||||
$finalStock = max(0, $newStock);
|
||||
$slot->update(['stock' => $finalStock]);
|
||||
|
||||
// 紀錄庫存變化供前端顯示
|
||||
$payloadUpdates['old_stock'] = $oldStock;
|
||||
$payloadUpdates['new_stock'] = $finalStock;
|
||||
|
||||
ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
@ -115,9 +107,23 @@ class ProcessCommandAck implements ShouldQueue
|
||||
'info',
|
||||
['slot' => $slotNo]
|
||||
);
|
||||
} else {
|
||||
// 若出貨失敗,執行回滾 (Rollback):將預扣的庫存加回去
|
||||
$oldStock = $command->payload['old_stock'] ?? $slot->stock;
|
||||
$slot->update(['stock' => $oldStock]);
|
||||
|
||||
$payloadUpdates['rolled_back'] = true;
|
||||
$payloadUpdates['new_stock'] = $oldStock;
|
||||
|
||||
Log::warning('MQTT Dispense failed, rolled back inventory', [
|
||||
'serial_no' => $this->serialNo,
|
||||
'slot_no' => $slotNo,
|
||||
'rolled_back_to' => $oldStock
|
||||
]);
|
||||
}
|
||||
}
|
||||
} elseif ($command->command_type === 'reload_stock') {
|
||||
}
|
||||
elseif ($command->command_type === 'reload_stock') {
|
||||
// 若同步失敗,自動 Rollback 回滾庫存
|
||||
if ($status === 'failed' && isset($command->payload['slot_no'], $command->payload['old'])) {
|
||||
$slotNo = $command->payload['slot_no'];
|
||||
|
||||
@ -203,46 +203,49 @@ class MachineService
|
||||
*/
|
||||
public function updateSlot(Machine $machine, array $data, ?int $userId = null): void
|
||||
{
|
||||
$command = DB::transaction(function () use ($machine, $data, $userId) {
|
||||
DB::transaction(function () use ($machine, $data, $userId) {
|
||||
$slotNo = $data['slot_no'];
|
||||
$stock = $data['stock'] ?? null;
|
||||
$expiryDate = $data['expiry_date'] ?? null;
|
||||
$batchNo = $data['batch_no'] ?? null;
|
||||
$slot = $machine->slots()->where('slot_no', $slotNo)->firstOrFail();
|
||||
|
||||
$slot = $machine->slots()->where('slot_no', $slotNo)->lockForUpdate()->firstOrFail();
|
||||
|
||||
// 紀錄舊數據以供 Payload 使用
|
||||
// 1. 併行檢查:確保同一個貨道在同一時間只有一個指令在執行中 (Pending)
|
||||
// 包含 reload_stock 與 dispense 兩種會異動庫存的類型
|
||||
$isPending = RemoteCommand::where('machine_id', $machine->id)
|
||||
->whereIn('command_type', ['reload_stock', 'dispense'])
|
||||
->where('status', 'pending')
|
||||
->whereJsonContains('payload->slot_no', (string)$slotNo)
|
||||
->exists();
|
||||
|
||||
if ($isPending) {
|
||||
throw new \Exception(__('This slot has a pending update. Please wait for the previous command to complete.'));
|
||||
}
|
||||
|
||||
// 紀錄舊數據以供回滾使用
|
||||
$oldData = [
|
||||
'stock' => $slot->stock,
|
||||
'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null,
|
||||
'batch_no' => $slot->batch_no,
|
||||
];
|
||||
|
||||
// 2. 執行樂觀更新 (Optimistic Update)
|
||||
$updateData = [
|
||||
'expiry_date' => $expiryDate,
|
||||
'batch_no' => $batchNo,
|
||||
];
|
||||
if ($stock !== null) $updateData['stock'] = (int)$stock;
|
||||
|
||||
$slot->update($updateData);
|
||||
|
||||
// 指令去重:將該機台所有尚未領取的舊庫存同步指令標記為「已取代」
|
||||
RemoteCommand::where('machine_id', $machine->id)
|
||||
->where('command_type', 'reload_stock')
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'superseded',
|
||||
'note' => __('Superseded by new adjustment'),
|
||||
'executed_at' => now(),
|
||||
]);
|
||||
|
||||
// 建立遠端指令紀錄 (Unified Command Concept)
|
||||
return RemoteCommand::create([
|
||||
// 3. 建立遠端指令紀錄
|
||||
$command = RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
'user_id' => $userId,
|
||||
'command_type' => 'reload_stock',
|
||||
'status' => 'pending',
|
||||
'payload' => [
|
||||
'slot_no' => $slotNo,
|
||||
'slot_no' => (string)$slotNo,
|
||||
'old' => $oldData,
|
||||
'new' => [
|
||||
'stock' => $stock !== null ? (int)$stock : $oldData['stock'],
|
||||
@ -251,26 +254,80 @@ class MachineService
|
||||
]
|
||||
]
|
||||
]);
|
||||
});
|
||||
|
||||
// 交易成功後,推播 MQTT 遠端指令
|
||||
$mqttPayload = [
|
||||
'data' => [
|
||||
[
|
||||
'slot_no' => $command->payload['slot_no'],
|
||||
'stock' => $command->payload['new']['stock'],
|
||||
'expiry_date' => $command->payload['new']['expiry_date'],
|
||||
'batch_no' => $command->payload['new']['batch_no'] ?? null,
|
||||
// 4. 推播 MQTT
|
||||
$mqttPayload = [
|
||||
'data' => [
|
||||
[
|
||||
'slot_no' => $command->payload['slot_no'],
|
||||
'stock' => $command->payload['new']['stock'],
|
||||
'expiry_date' => $command->payload['new']['expiry_date'],
|
||||
'batch_no' => $command->payload['new']['batch_no'] ?? null,
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
];
|
||||
|
||||
app(\App\Services\Machine\MqttService::class)->pushCommand(
|
||||
$machine->serial_no,
|
||||
'update_inventory',
|
||||
$mqttPayload,
|
||||
(string) $command->id
|
||||
);
|
||||
app(\App\Services\Machine\MqttService::class)->pushCommand(
|
||||
$machine->serial_no,
|
||||
'update_inventory',
|
||||
$mqttPayload,
|
||||
(string) $command->id
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 遠端出貨指令下發 (含樂觀扣庫存與鎖定)
|
||||
*/
|
||||
public function dispatchDispense(Machine $machine, string $slotNo, ?int $userId = null): RemoteCommand
|
||||
{
|
||||
return DB::transaction(function () use ($machine, $slotNo, $userId) {
|
||||
$slot = $machine->slots()->where('slot_no', $slotNo)->lockForUpdate()->firstOrFail();
|
||||
|
||||
// 併行檢查
|
||||
$isPending = RemoteCommand::where('machine_id', $machine->id)
|
||||
->whereIn('command_type', ['reload_stock', 'dispense'])
|
||||
->where('status', 'pending')
|
||||
->whereJsonContains('payload->slot_no', (string)$slotNo)
|
||||
->exists();
|
||||
|
||||
if ($isPending) {
|
||||
throw new \Exception(__('This slot has a pending command. Please wait.'));
|
||||
}
|
||||
|
||||
if ($slot->stock <= 0) {
|
||||
throw new \Exception(__('Out of stock.'));
|
||||
}
|
||||
|
||||
$oldStock = $slot->stock;
|
||||
$newStock = $oldStock - 1;
|
||||
|
||||
// 1. 執行樂觀扣薪
|
||||
$slot->update(['stock' => $newStock]);
|
||||
|
||||
// 2. 建立指令紀錄
|
||||
$command = RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
'user_id' => $userId,
|
||||
'command_type' => 'dispense',
|
||||
'status' => 'pending',
|
||||
'payload' => [
|
||||
'slot_no' => (string)$slotNo,
|
||||
'old_stock' => $oldStock,
|
||||
'new_stock' => $newStock,
|
||||
]
|
||||
]);
|
||||
|
||||
// 3. 推播 MQTT
|
||||
app(\App\Services\Machine\MqttService::class)->pushCommand(
|
||||
$machine->serial_no,
|
||||
'dispense',
|
||||
$command->payload,
|
||||
(string) $command->id
|
||||
);
|
||||
|
||||
return $command;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
20
lang/en.json
20
lang/en.json
@ -815,7 +815,7 @@
|
||||
"Sale Price": "Sale Price",
|
||||
"Sales": "Sales",
|
||||
"sales": "sales",
|
||||
"Sales Activity": "銷售活動",
|
||||
"Sales Activity": "Sales Activity",
|
||||
"Sales Management": "Sales Management",
|
||||
"Sales Permissions": "Sales Permissions",
|
||||
"Sales Records": "Sales Records",
|
||||
@ -823,10 +823,10 @@
|
||||
"Save Changes": "Save Changes",
|
||||
"Save Config": "Save Config",
|
||||
"Save Material": "Save Material",
|
||||
"Save Permissions": "儲存權限",
|
||||
"Save Permissions": "Save Permissions",
|
||||
"Saved.": "Saved.",
|
||||
"Saving...": "儲存中...",
|
||||
"Scale level and access control": "層級與存取控制",
|
||||
"Saving...": "Saving...",
|
||||
"Scale level and access control": "Scale level and access control",
|
||||
"Scan this code to quickly access the maintenance form for this device.": "Scan this code to quickly access the maintenance form for this device.",
|
||||
"Search accounts...": "Search accounts...",
|
||||
"Search by name or S/N...": "Search by name or S/N...",
|
||||
@ -1178,5 +1178,15 @@
|
||||
"Unauthorized: Account not found": "Unauthorized: Account not found",
|
||||
"Unauthorized: Account not authorized for this machine": "Unauthorized: Account not authorized for this machine",
|
||||
"Slot report synchronized success": "Slot report synchronized success",
|
||||
"Error: :message (:code)": "Error: :message (:code)"
|
||||
"Error: :message (:code)": "Error: :message (:code)",
|
||||
"Syncing": "Syncing",
|
||||
"This slot is syncing with the machine. Please wait.": "This slot is syncing with the machine. Please wait.",
|
||||
"Loading Data": "Loading Data",
|
||||
"All Status": "All Status",
|
||||
"CASH": "CASH",
|
||||
"ITEM": "ITEM",
|
||||
"This slot has a pending update. Please wait for the previous command to complete.": "This slot has a pending update. Please wait for the previous command to complete.",
|
||||
"This slot has a pending command. Please wait.": "This slot has a pending command. Please wait.",
|
||||
"Out of stock.": "Out of stock.",
|
||||
"Save error:": "Save error:"
|
||||
}
|
||||
1180
lang/ja.json
1180
lang/ja.json
File diff suppressed because it is too large
Load Diff
@ -433,7 +433,7 @@
|
||||
"LINE_MERCHANT_ID": "LINE Pay 商店代號",
|
||||
"LIVE": "實時",
|
||||
"Live Fleet Updates": "即時數據更新",
|
||||
"Loading Cabinet...": "正在載入貨道...",
|
||||
"Loading Cabinet...": "正在載入機台貨盤...",
|
||||
"Loading machines...": "正在載入機台...",
|
||||
"Loading...": "載入中...",
|
||||
"Location": "位置",
|
||||
@ -619,7 +619,7 @@
|
||||
"No matching machines": "查無匹配機台",
|
||||
"No materials available": "沒有可用的素材",
|
||||
"No permissions": "無權限項目",
|
||||
"No records found": "查無操作紀錄",
|
||||
"No records found": "未找到相關紀錄",
|
||||
"No roles available": "目前沒有角色資料。",
|
||||
"No roles found.": "找不到角色資料。",
|
||||
"No slots found": "未找到貨道資訊",
|
||||
@ -698,7 +698,7 @@
|
||||
"Photo Slot": "照片欄位",
|
||||
"PI_MERCHANT_ID": "Pi 拍錢包 商店代號",
|
||||
"Picked up": "領取",
|
||||
"Picked up Time": "讀取時間",
|
||||
"Picked up Time": "領取時間",
|
||||
"Pickup Code": "取貨碼",
|
||||
"Pickup Codes": "取貨碼",
|
||||
"Playback Order": "播放順序",
|
||||
@ -998,7 +998,7 @@
|
||||
"Spring": "彈簧",
|
||||
"Transfer Audit": "調撥單",
|
||||
"Transfers": "調撥單",
|
||||
"Trigger": "觸發",
|
||||
"Trigger": "執行",
|
||||
"Trigger Dispense": "觸發出貨",
|
||||
"Trigger Remote Dispense": "觸發遠端出貨",
|
||||
"Tutorial Page": "教學頁",
|
||||
@ -1188,5 +1188,14 @@
|
||||
"Unauthorized: Account not found": "授權失敗:找不到帳號",
|
||||
"Unauthorized: Account not authorized for this machine": "授權失敗:此帳號無存取該機台的權限",
|
||||
"Slot report synchronized success": "貨道狀態同步成功",
|
||||
"Error: :message (:code)": "錯誤::message (:code)"
|
||||
"Error: :message (:code)": "錯誤::message (:code)",
|
||||
"Syncing": "同步中",
|
||||
"This slot is syncing with the machine. Please wait.": "此貨道正在與機台同步中,請稍候。",
|
||||
"Loading Data": "載入資料中",
|
||||
"CASH": "CASH",
|
||||
"ITEM": "ITEM",
|
||||
"This slot has a pending update. Please wait for the previous command to complete.": "此貨道已有更新指令在執行中,請等候前一個指令完成。",
|
||||
"This slot has a pending command. Please wait.": "此貨道已有指令正在執行,請稍後。",
|
||||
"Out of stock.": "庫存不足。",
|
||||
"Save error:": "儲存錯誤:"
|
||||
}
|
||||
@ -56,6 +56,7 @@
|
||||
'Amount' => __('Amount'),
|
||||
'Command error:' => __('Command error:'),
|
||||
'Command has been queued successfully.' => __('Command has been queued successfully.'),
|
||||
'Remote Settlement' => __('Remote Settlement'),
|
||||
'Just now' => __('Just now'),
|
||||
'mins ago' => __('mins ago'),
|
||||
'hours ago' => __('hours ago'),
|
||||
@ -69,8 +70,10 @@
|
||||
|
||||
async init() {
|
||||
// Watch for machine data changes to rebuild slot select
|
||||
this.$watch('selectedMachine.slots', () => {
|
||||
this.$nextTick(() => this.updateSlotSelect());
|
||||
this.$watch('selectedMachine', (val) => {
|
||||
if (val && val.slots) {
|
||||
this.$nextTick(() => this.updateSlotSelect());
|
||||
}
|
||||
});
|
||||
|
||||
// 首次載入時綁定分頁連結
|
||||
@ -376,8 +379,12 @@
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
window.location.href = this.appConfig.indexUrl;
|
||||
} else {
|
||||
throw new Error(data.message || 'Unknown error');
|
||||
}
|
||||
} catch (e) {
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
@ -424,7 +431,7 @@
|
||||
const names = {
|
||||
'reboot': this.translations['Machine Reboot'],
|
||||
'reboot_card': this.translations['Card Reader Reboot'],
|
||||
'checkout': this.translations['Remote Reboot'],
|
||||
'checkout': this.translations['Remote Settlement'],
|
||||
'lock': this.translations['Lock Page Lock'],
|
||||
'unlock': this.translations['Lock Page Unlock'],
|
||||
'change': this.translations['Remote Change'],
|
||||
@ -452,7 +459,8 @@
|
||||
translateNote(note) {
|
||||
if (!note) return '';
|
||||
const translations = {
|
||||
'Superseded by new adjustment': this.translations['Superseded by new adjustment']
|
||||
'Superseded by new adjustment': this.translations['Superseded by new adjustment'],
|
||||
'Superseded by new command': this.translations['Superseded by new command']
|
||||
};
|
||||
return translations[note] || note;
|
||||
},
|
||||
@ -485,7 +493,7 @@
|
||||
|
||||
if (item.command_type === 'dispense' && item.payload) {
|
||||
let details = `${this.translations['Slot']} ${item.payload.slot_no}`;
|
||||
if (item.status === 'success' && item.payload.old_stock !== undefined) {
|
||||
if (item.payload.old_stock !== undefined && item.payload.new_stock !== undefined) {
|
||||
details += `: ${this.translations['Stock']} ${item.payload.old_stock} → ${item.payload.new_stock}`;
|
||||
}
|
||||
return details;
|
||||
@ -752,7 +760,7 @@
|
||||
</h3>
|
||||
<div
|
||||
class="text-[40px] font-black text-slate-200 dark:text-slate-800 leading-none select-none tracking-tighter">
|
||||
CASH</div>
|
||||
{{ __('CASH') }}</div>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<div class="relative group">
|
||||
@ -799,7 +807,7 @@
|
||||
</h3>
|
||||
<div
|
||||
class="text-[40px] font-black text-slate-200 dark:text-slate-800 leading-none select-none tracking-tighter">
|
||||
ITEM</div>
|
||||
{{ __('ITEM') }}</div>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
@ -876,6 +884,7 @@
|
||||
</div>
|
||||
<template x-if="getPayloadDetails(cmd)">
|
||||
<div class="px-3 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-sm font-bold text-cyan-600 dark:text-cyan-400 break-words leading-relaxed"
|
||||
:class="cmd.status === 'failed' ? 'line-through opacity-60' : ''"
|
||||
x-text="getPayloadDetails(cmd)">
|
||||
</div>
|
||||
</template>
|
||||
@ -892,8 +901,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<div class="text-[10px] font-black uppercase tracking-widest">{{ __('No command
|
||||
history') }}</div>
|
||||
<div class="text-[10px] font-black uppercase tracking-widest">{{ __('No command history') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@ -937,16 +945,14 @@
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight uppercase">{{
|
||||
__('Command Confirmation') }}</h3>
|
||||
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mt-0.5">{{ __('Please
|
||||
confirm the details below') }}</p>
|
||||
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mt-0.5">{{ __('Please confirm the details below') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="space-y-4 bg-slate-50 dark:bg-slate-950/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800/50 mb-8">
|
||||
<div class="flex justify-between items-center px-1">
|
||||
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Command
|
||||
Type') }}</span>
|
||||
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Command Type') }}</span>
|
||||
<span class="text-sm font-black text-slate-800 dark:text-slate-200"
|
||||
x-text="getCommandName(confirmModal.type)"></span>
|
||||
</div>
|
||||
|
||||
@ -69,7 +69,7 @@
|
||||
value="{{ request('start_date') }}">
|
||||
<input type="hidden" name="end_date" x-ref="endDate" value="{{ request('end_date') }}">
|
||||
<input type="text" x-ref="dateRange"
|
||||
value="{{ request('start_date') && request('end_date') ? request('start_date') . ' 至 ' . request('end_date') : (request('start_date') ?: '') }}"
|
||||
value="{{ request('start_date') && request('end_date') ? request('start_date') . ' ' . __('to') . ' ' . request('end_date') : (request('start_date') ?: '') }}"
|
||||
placeholder="{{ __('Select Date Range') }}"
|
||||
class="luxury-input py-2.5 pl-12 pr-6 block w-full cursor-pointer">
|
||||
</div>
|
||||
@ -197,6 +197,7 @@
|
||||
<div class="flex flex-col gap-0.5 mt-1">
|
||||
<span x-show="getPayloadDetails({{ Js::from($item) }})"
|
||||
class="text-[11px] font-bold text-cyan-600 dark:text-cyan-400/80 bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10 w-fit"
|
||||
:class="{'line-through opacity-60': {{ Js::from($item->status) }} === 'failed'}"
|
||||
x-text="getPayloadDetails({{ Js::from($item) }})"></span>
|
||||
@if($item->note)
|
||||
<span class="text-[10px] text-slate-400 italic pl-1"
|
||||
|
||||
@ -133,7 +133,10 @@
|
||||
<div class="flex flex-col min-w-[200px]">
|
||||
<span class="text-sm font-black text-slate-700 dark:text-slate-300 tracking-tight" x-text="getCommandName(@js($item->command_type))"></span>
|
||||
<div class="flex flex-col gap-0.5 mt-1">
|
||||
<span x-show="getPayloadDetails(@js($item))" class="text-[11px] font-bold text-cyan-600 dark:text-cyan-400/80 bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10 w-fit" x-text="getPayloadDetails(@js($item))"></span>
|
||||
<span x-show="getPayloadDetails(@js($item))"
|
||||
class="text-[11px] font-bold text-cyan-600 dark:text-cyan-400/80 bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10 w-fit"
|
||||
:class="{'line-through opacity-60': @js($item->status) === 'failed'}"
|
||||
x-text="getPayloadDetails(@js($item))"></span>
|
||||
@if($item->note)
|
||||
<span class="text-[10px] text-slate-400 italic pl-1" x-text="translateNote(@js($item->note))"></span>
|
||||
@endif
|
||||
|
||||
@ -190,6 +190,15 @@
|
||||
},
|
||||
|
||||
openEdit(slot) {
|
||||
if (slot.is_pending) {
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
detail: {
|
||||
message: '{{ __("This slot is syncing with the machine. Please wait.") }}',
|
||||
type: 'warning'
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
this.selectedSlot = slot;
|
||||
this.formData = {
|
||||
stock: slot.stock || 0,
|
||||
@ -214,10 +223,9 @@
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.showEditModal = false;
|
||||
|
||||
// Redirect instantly to history tab.
|
||||
// The success toast will be handled by the session() flash in the controller.
|
||||
window.location.href = "{{ route('admin.remote.stock') }}";
|
||||
} else {
|
||||
throw new Error(data.message || 'Unknown error');
|
||||
}
|
||||
} catch (e) {
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
@ -261,7 +269,7 @@
|
||||
const names = {
|
||||
'reboot': {{ Js::from(__('Machine Reboot')) }},
|
||||
'reboot_card': {{ Js::from(__('Card Reader Reboot')) }},
|
||||
'checkout': {{ Js::from(__('Remote Reboot')) }},
|
||||
'checkout': {{ Js::from(__('Remote Settlement')) }},
|
||||
'lock': {{ Js::from(__('Lock Page Lock')) }},
|
||||
'unlock': {{ Js::from(__('Lock Page Unlock')) }},
|
||||
'change': {{ Js::from(__('Remote Change')) }},
|
||||
@ -331,7 +339,8 @@
|
||||
translateNote(note) {
|
||||
if (!note) return '';
|
||||
const translations = {
|
||||
'Superseded by new adjustment': {{ Js::from(__('Superseded by new adjustment')) }}
|
||||
'Superseded by new adjustment': {{ Js::from(__('Superseded by new adjustment')) }},
|
||||
'Superseded by new command': {{ Js::from(__('Superseded by new command')) }}
|
||||
};
|
||||
return translations[note] || note;
|
||||
}
|
||||
@ -583,8 +592,18 @@
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6 relative z-10"
|
||||
x-show="!loading">
|
||||
<template x-for="slot in slots" :key="slot.id">
|
||||
<div @click="openEdit(slot)" :class="getSlotColorClass(slot)"
|
||||
class="min-h-[300px] rounded-[2.5rem] p-5 flex flex-col items-center justify-center border-2 transition-all duration-500 cursor-pointer group hover:scale-[1.08] hover:-translate-y-3 hover:shadow-2xl active:scale-[0.98] relative">
|
||||
<div @click="openEdit(slot)" :class="[getSlotColorClass(slot), slot.is_pending ? 'opacity-50 cursor-not-allowed grayscale-[0.5]' : 'cursor-pointer hover:scale-[1.08] hover:-translate-y-3 hover:shadow-2xl active:scale-[0.98]']"
|
||||
class="min-h-[300px] rounded-[2.5rem] p-5 flex flex-col items-center justify-center border-2 transition-all duration-500 group relative">
|
||||
|
||||
<!-- Pending Overlay -->
|
||||
<template x-if="slot.is_pending">
|
||||
<div class="absolute inset-0 z-30 flex items-center justify-center bg-white/10 dark:bg-slate-900/10 backdrop-blur-[1px] rounded-[2.5rem]">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="w-8 h-8 border-2 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin"></div>
|
||||
<span class="text-[8px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-widest">{{ __('Syncing') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Slot Header (Pinned to top) -->
|
||||
<div class="absolute top-4 left-5 right-5 flex justify-between items-center z-20">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user