diff --git a/.agents/skills/api-technical-specs/SKILL.md b/.agents/skills/api-technical-specs/SKILL.md index aec6116..175f16e 100644 --- a/.agents/skills/api-technical-specs/SKILL.md +++ b/.agents/skills/api-technical-specs/SKILL.md @@ -50,6 +50,8 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與 - `4`: 補貨頁 / `5`: 教學頁 / `6`: 購買中 / `7`: 鎖定頁 - `60`: 出貨成功 / `61`: 貨道測試 / `62`: 付款選擇 - `63`: 等待付款 / `64`: 出貨 / `65`: 收據簽單 +- `66`: 通行碼 / `67`: 取貨碼 / `68`: 訊息顯示 +- `69`: 取消購買 / `610`: 購買結束 / `611`: 來店禮 - `612`: 出貨失敗 **雲端指令代碼 (status):** @@ -62,6 +64,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與 - `72`: sellCode reload B023 (即期品) - `75`: exp reload B026 (效期) - `79`: read B050 (參數讀取) +- `81`: sync timer status (B710) - `85`: reload B0552 (出貨腳本) --- diff --git a/app/Http/Controllers/Admin/MachineController.php b/app/Http/Controllers/Admin/MachineController.php index 8df0cd9..7aa02e5 100644 --- a/app/Http/Controllers/Admin/MachineController.php +++ b/app/Http/Controllers/Admin/MachineController.php @@ -131,7 +131,9 @@ class MachineController extends AdminController 'batch_no' => 'nullable|string|max:50', ]); - $this->machineService->updateSlot($machine, $validated); + $this->machineService->updateSlot($machine, $validated, auth()->id()); + + session()->flash('success', __('Slot updated successfully.')); return response()->json([ 'success' => true, diff --git a/app/Http/Controllers/Admin/RemoteController.php b/app/Http/Controllers/Admin/RemoteController.php index bbec9c6..21b3c21 100644 --- a/app/Http/Controllers/Admin/RemoteController.php +++ b/app/Http/Controllers/Admin/RemoteController.php @@ -15,18 +15,32 @@ class RemoteController extends Controller */ public function index(Request $request) { - $machines = Machine::withCount(['slots'])->orderBy('name')->get(); + $machines = Machine::withCount(['slots'])->orderBy('last_heartbeat_at', 'desc')->orderBy('id', 'desc')->get(); $selectedMachine = null; + $history = RemoteCommand::where('command_type', '!=', 'reload_stock')->with(['machine', 'user'])->latest()->limit(50)->get(); if ($request->has('machine_id')) { $selectedMachine = Machine::with(['slots.product', 'commands' => function($query) { - $query->latest()->limit(10); + $query->where('command_type', '!=', 'reload_stock') + ->latest() + ->limit(10); }])->find($request->machine_id); } + if ($request->ajax()) { + return response()->json([ + 'success' => true, + 'machine' => $selectedMachine, + 'commands' => $selectedMachine ? $selectedMachine->commands : [] + ]); + } + return view('admin.remote.index', [ 'machines' => $machines, 'selectedMachine' => $selectedMachine, + 'history' => $history, + 'title' => __('Remote Command Center'), + 'subtitle' => __('Execute maintenance and operational commands remotely') ]); } @@ -50,15 +64,35 @@ class RemoteController extends Controller $payload['slot_no'] = $validated['slot_no']; } + // 指令去重:將同機台、同類型的 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(), + ]); + RemoteCommand::create([ 'machine_id' => $validated['machine_id'], + 'user_id' => auth()->id(), 'command_type' => $validated['command_type'], 'payload' => $payload, 'status' => 'pending', 'note' => $validated['note'] ?? null, ]); - return redirect()->back()->with('success', __('Command has been queued successfully.')); + session()->flash('success', __('Command has been queued successfully.')); + + if ($request->expectsJson()) { + return response()->json([ + 'success' => true, + 'message' => __('Command has been queued successfully.') + ]); + } + + return redirect()->back(); } /** @@ -70,17 +104,31 @@ class RemoteController extends Controller 'slots as slots_count', 'slots as low_stock_count' => function ($query) { $query->where('stock', '<=', 5); + }, + 'slots as expiring_soon_count' => function ($query) { + $query->whereNotNull('expiry_date') + ->where('expiry_date', '<=', now()->addDays(7)) + ->where('expiry_date', '>=', now()->startOfDay()); } - ])->orderBy('name')->get(); + ])->orderBy('last_heartbeat_at', 'desc')->orderBy('id', 'desc')->get(); + $history = RemoteCommand::where('command_type', 'reload_stock')->with(['machine', 'user'])->latest()->limit(50)->get(); + $selectedMachine = null; if ($request->has('machine_id')) { - $selectedMachine = Machine::with('slots.product')->find($request->machine_id); + $selectedMachine = Machine::with(['slots.product', 'commands' => function($query) { + $query->where('command_type', 'reload_stock') + ->latest() + ->limit(50); + }])->find($request->machine_id); } return view('admin.remote.stock', [ 'machines' => $machines, 'selectedMachine' => $selectedMachine, + 'history' => $history, + 'title' => __('Stock & Expiry Management'), + 'subtitle' => __('Real-time monitoring and adjustment of cargo lane inventory and expiration dates') ]); } } diff --git a/app/Http/Controllers/Api/V1/App/MachineController.php b/app/Http/Controllers/Api/V1/App/MachineController.php index 51cbbd8..6c2dd12 100644 --- a/app/Http/Controllers/Api/V1/App/MachineController.php +++ b/app/Http/Controllers/Api/V1/App/MachineController.php @@ -104,6 +104,12 @@ class MachineController extends Controller { $machine = $request->get('machine'); $slots = $machine->slots()->with('product')->get(); + + // 自動轉 Success: 若機台來撈 B017,代表之前的 reload_stock 指令已成功被機台響應 + \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id) + ->where('command_type', 'reload_stock') + ->where('status', 'sent') + ->update(['status' => 'success', 'executed_at' => now()]); return response()->json([ 'success' => true, diff --git a/app/Models/Machine/Machine.php b/app/Models/Machine/Machine.php index da15586..26df42c 100644 --- a/app/Models/Machine/Machine.php +++ b/app/Models/Machine/Machine.php @@ -106,6 +106,11 @@ class Machine extends Model return $this->hasMany(MachineSlot::class); } + public function commands() + { + return $this->hasMany(RemoteCommand::class); + } + public function machineModel() { return $this->belongsTo(MachineModel::class); @@ -133,21 +138,20 @@ class Machine extends Model '3' => 'Admin Page', '4' => 'Replenishment Page', '5' => 'Tutorial Page', - '60' => 'Purchasing', - '61' => 'Locked Page', - '62' => 'Dispense Failed', - '301' => 'Slot Test', - '302' => 'Slot Test', - '401' => 'Payment Selection', - '402' => 'Waiting for Payment', - '403' => 'Dispensing', - '404' => 'Receipt Printing', - '601' => 'Pass Code', - '602' => 'Pickup Code', - '603' => 'Message Display', - '604' => 'Cancel Purchase', - '605' => 'Purchase Finished', - '611' => 'Welcome Gift Status', + '6' => 'Purchasing', + '7' => 'Locked Page', + '60' => 'Dispense Success', + '61' => 'Slot Test', + '62' => 'Payment Selection', + '63' => 'Waiting for Payment', + '64' => 'Dispensing', + '65' => 'Receipt Printing', + '66' => 'Pass Code', + '67' => 'Pickup Code', + '68' => 'Message Display', + '69' => 'Cancel Purchase', + '610' => 'Purchase Finished', + '611' => 'Welcome Gift', '612' => 'Dispense Failed', ]; diff --git a/app/Models/Machine/RemoteCommand.php b/app/Models/Machine/RemoteCommand.php index f70e66c..27ba7af 100644 --- a/app/Models/Machine/RemoteCommand.php +++ b/app/Models/Machine/RemoteCommand.php @@ -11,6 +11,7 @@ class RemoteCommand extends Model protected $fillable = [ 'machine_id', + 'user_id', 'command_type', 'payload', 'status', @@ -28,6 +29,11 @@ class RemoteCommand extends Model return $this->belongsTo(Machine::class); } + public function user() + { + return $this->belongsTo(\App\Models\System\User::class); + } + /** * Scope for pending commands */ diff --git a/app/Services/Machine/MachineService.php b/app/Services/Machine/MachineService.php index f2e9898..367836e 100644 --- a/app/Services/Machine/MachineService.php +++ b/app/Services/Machine/MachineService.php @@ -4,6 +4,7 @@ namespace App\Services\Machine; use App\Models\Machine\Machine; use App\Models\Machine\MachineLog; +use App\Models\Machine\RemoteCommand; use Illuminate\Support\Facades\DB; use Carbon\Carbon; @@ -96,17 +97,25 @@ class MachineService * * @param Machine $machine * @param array $data + * @param int|null $userId * @return void */ - public function updateSlot(Machine $machine, array $data): void + public function updateSlot(Machine $machine, array $data, ?int $userId = null): void { - DB::transaction(function () use ($machine, $data) { + 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(); + // 紀錄舊數據以供 Payload 使用 + $oldData = [ + 'stock' => $slot->stock, + 'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null, + 'batch_no' => $slot->batch_no, + ]; + $updateData = [ 'expiry_date' => $expiryDate, 'batch_no' => $batchNo, @@ -114,6 +123,33 @@ class MachineService 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) + RemoteCommand::create([ + 'machine_id' => $machine->id, + 'user_id' => $userId, + 'command_type' => 'reload_stock', + 'status' => 'pending', + 'payload' => [ + 'slot_no' => $slotNo, + 'old' => $oldData, + 'new' => [ + 'stock' => $stock !== null ? (int)$stock : $oldData['stock'], + 'expiry_date' => $expiryDate ?: null, + 'batch_no' => $batchNo ?: null, + ] + ] + ]); }); } diff --git a/database/migrations/2026_04_02_100848_add_user_id_to_remote_commands_table.php b/database/migrations/2026_04_02_100848_add_user_id_to_remote_commands_table.php new file mode 100644 index 0000000..056be0e --- /dev/null +++ b/database/migrations/2026_04_02_100848_add_user_id_to_remote_commands_table.php @@ -0,0 +1,28 @@ +foreignId('user_id')->nullable()->after('machine_id')->constrained()->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('remote_commands', function (Blueprint $table) { + $table->dropConstrainedForeignId('user_id'); + }); + } +}; diff --git a/database/migrations/2026_04_02_110119_update_remote_commands_status_enum.php b/database/migrations/2026_04_02_110119_update_remote_commands_status_enum.php new file mode 100644 index 0000000..77d972d --- /dev/null +++ b/database/migrations/2026_04_02_110119_update_remote_commands_status_enum.php @@ -0,0 +1,28 @@ + __('Please select a slot'), + 'Search cargo lane' => __('Search cargo lane'), + 'Stock:' => __('Stock:'), + 'Loading...' => __('Loading...'), + 'No active cargo lanes found' => __('No active cargo lanes found'), + 'Empty' => __('Empty'), + 'Machine Reboot' => __('Machine Reboot'), + 'Card Reader Reboot' => __('Card Reader Reboot'), + 'Remote Reboot' => __('Remote Reboot'), + 'Lock Page Lock' => __('Lock Page Lock'), + 'Lock Page Unlock' => __('Lock Page Unlock'), + 'Remote Change' => __('Remote Change'), + 'Remote Dispense' => __('Remote Dispense'), + 'Adjust Stock & Expiry' => __('Adjust Stock & Expiry'), + 'Pending' => __('Pending'), + 'Sent' => __('Sent'), + 'Success' => __('Success'), + 'Failed' => __('Failed'), + 'Superseded' => __('Superseded'), + 'System' => __('System'), + 'Superseded by new adjustment' => __('Superseded by new adjustment'), + 'Superseded by new command' => __('Superseded by new command'), + 'Slot' => __('Slot'), + 'Stock' => __('Stock'), + 'Expiry' => __('Expiry'), + 'Batch' => __('Batch'), + 'Amount' => __('Amount'), + 'Command error:' => __('Command error:'), + 'Command has been queued successfully.' => __('Command has been queued successfully.'), + 'Just now' => __('Just now'), + 'mins ago' => __('mins ago'), + 'hours ago' => __('hours ago'), + ]), + // Form States - lockStatus: false, // false = unlocked, true = locked + lockStatus: false, changeAmount: 100, selectedSlot: '', note: '', @@ -25,6 +69,95 @@ window.remoteControlApp = function(initialMachineId) { await this.selectMachine(machine); } } + + // Watch for machine data changes to rebuild slot select + this.$watch('selectedMachine.slots', () => { + this.$nextTick(() => this.updateSlotSelect()); + }); + }, + + updateSlotSelect() { + const wrapper = document.getElementById('slot-select-wrapper'); + if (!wrapper) return; + + // Clear previous and reset + const oldSelect = wrapper.querySelector('select'); + if (oldSelect) { + try { + const instance = window.HSSelect.getInstance(oldSelect); + if (instance) instance.destroy(); + } catch (e) {} + } + wrapper.innerHTML = ''; + + // If loading, show a skeleton or simple text + if (this.loading) { + wrapper.innerHTML = `
+ {{ __($subtitle ?? 'Execute maintenance and operational commands remotely') }} +
+- {{ __('Execute maintenance and operational commands remotely') }} -
-| {{ __('Machine Information') }} | +{{ __('Creation Time') }} | +{{ __('Picked up Time') }} | +{{ __('Command Type') }} | +{{ __('Operator') }} | +{{ __('Status') }} | +
|---|---|---|---|---|---|
|
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ -
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ |
+
|
+
+
+
+
+
+ {{ __('No records found') }} + |
+ |||||
| {{ __('Machine Information') }} | +{{ __('Status') }} | +{{ __('Last Communication') }} | +{{ __('Actions') }} | +
|---|---|---|---|
|
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __('Online') }}
+
+
+ {{ __('Offline') }}
+
+
+
+
+
+
+
+
+
+
+ {{ __('Abnormal') }}
+ |
+
+
+
+
+
+ |
+ + + | +