[FEAT] 實作「同步到所有機台」功能與商品資料同步機制

1. 在商品管理介面新增「同步到所有機台」按鈕與 Alpine.js 確認彈窗。
2. 實作後端 syncToAllMachines 控制器邏輯,並加入同一公司 1 分鐘內禁止重複發送的限制。
3. 建立 SyncProductsToMachinesJob 與 SendProductSyncCommandJob 非同步隊列任務,支援舊指令覆蓋機制。
4. 補全繁體中文、英文與日文的多語系翻譯字串。
5. 更新遠端指令日誌視圖,支援展示商品同步指令及其備註。
6. 更新 MQTT 通訊規範與 API 技術規格文檔,納入 update_products 指令。
7. 整合 update_ads 與 update_products 指令至 config/api-docs.php 的系統指令規格中。
This commit is contained in:
sky121113 2026-05-14 09:33:43 +08:00
parent 2c8a2de81c
commit 9ff9abef89
13 changed files with 1404 additions and 995 deletions

View File

@ -185,35 +185,6 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
---
### 3.8 B017: 貨道庫存同步 (Slot Synchronization)
用於機台端**主動**向遠端後台拉取目前所有貨道的最新庫存、效期與配置狀態。通常發生於機台開機初始化、補貨/維護模式結束,或是機台需要確保本地端庫存資料與雲端一致時主動請求。
- **URL**: GET /api/v1/app/machine/reload_msg/B017
- **Authentication**: Bearer Token (Header)
- **Request Body:** 無 (由 Token 自動識別機台)
- **Response Body:**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | true |
| code | Integer | 200 | 200 |
| data | Array | 貨道數據陣列 (依 slot_no 排序) | 見下表 |
**data 陣列內部欄位:**
| 欄位 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| tid | String | 貨道編號 | "1" |
| num | Integer | 當前庫存數量 | 10 |
| expiry_date | String | 商品效期 | "2026-12-31" |
| batch_no | String | 批號 | "B202604" |
| product_id | Integer | 商品 ID | 1 |
| capacity | Integer | 貨道容量上限 | 15 |
| status | String | 貨道狀態 ("1": 啟用, "0": 停用) | "1" |
---
### 3.9 B660: 取貨碼驗證 (Pickup Code)
處理機台端的 8 位數代碼取貨流程。
@ -304,6 +275,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| B601 | 發票紀錄 | `machine/{serial_no}/transaction` | `action: invoice` |
| B602 | 出貨結果 | `machine/{serial_no}/transaction` | `action: dispense` |
| B600+ | 交易完成(統整) | `machine/{serial_no}/transaction` | `action: finalize` |
| [New] | 商品同步指令 | `machine/{serial_no}/command` | `command: update_products` |
| B055 (PUT) | 遠端出貨回報 | `machine/{serial_no}/command/ack` | GET 查詢路由保留 |
---

View File

@ -280,6 +280,7 @@ Mqtt3Client client = MqttClient.builder()
- `update_inventory`: 遠端即時修改庫存。
- `dispense`: 遠端出貨指令。
- `update_ads`: 遠端廣告同步指令。
- `update_products`: 遠端商品資料同步指令(觸發機台調用 B012
**`update_inventory` Payload 範例:**
```json
@ -295,6 +296,17 @@ Mqtt3Client client = MqttClient.builder()
}
```
**`update_products` Payload 範例:**
```json
{
"command": "update_products",
"command_id": "123",
"payload": {
"remark": "手動同步商品資料"
}
}
```
### 3.5 指令回覆 (Command ACK) - `machine/{serial_no}/command/ack`
機台執行完雲端下發的指令後,透過此 Topic 回報執行結果。雲端收到後會根據 `command_id` 更新操作紀錄狀態,若結果為 `failed` 且為庫存指令,將觸發自動回滾 (Rollback)。

View File

@ -434,4 +434,53 @@ class ProductController extends Controller
return redirect()->back()->with('error', $e->getMessage());
}
}
/**
* 同步商品目錄至全公司機台 (Phase 2)
*/
public function syncToAllMachines(Request $request)
{
$user = auth()->user();
// 1. 決定目標公司 ID
$companyId = $user->isSystemAdmin()
? $request->input('company_id')
: $user->company_id;
if (!$companyId) {
return response()->json([
'success' => false,
'message' => __('Please select a company.')
], 422);
}
// 3. 頻率限制1 分鐘內不可重複對同一家公司發送同步指令
$machineIds = \App\Models\Machine\Machine::where('company_id', $companyId)->pluck('id');
$recentCommand = \App\Models\Machine\RemoteCommand::whereIn('machine_id', $machineIds)
->where('command_type', 'update_products')
->where('created_at', '>=', now()->subMinute())
->whereIn('status', ['pending', 'sent'])
->exists();
if ($recentCommand) {
return response()->json([
'success' => false,
'message' => __('A sync command was recently sent. Please wait 1 minute.')
], 422);
}
$remark = $request->input('remark') ?: __('Manual Sync Products');
// 2. 派遣延遲廣播 Job
\App\Jobs\Product\SyncProductsToMachinesJob::dispatch(
(int) $companyId,
$remark,
$user->id
);
return response()->json([
'success' => true,
'message' => __('Batch sync command has been queued. Machines will be updated sequentially.')
]);
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Jobs\Product;
use App\Models\Machine\Machine;
use App\Models\Machine\RemoteCommand;
use App\Services\Machine\MqttService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SendProductSyncCommandJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @param int $machineId
* @param string|null $remark
* @param int|null $userId
*/
public function __construct(
protected int $machineId,
protected ?string $remark = null,
protected ?int $userId = null
) {}
/**
* Execute the job.
*
* @param MqttService $mqttService
* @return void
*/
public function handle(MqttService $mqttService): void
{
$machine = Machine::find($this->machineId);
if (!$machine) {
Log::error("SendProductSyncCommandJob: Machine not found", ['id' => $this->machineId]);
return;
}
// 覆蓋任何該機台尚在「待處理」狀態的商品同步指令
RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'update_products')
->where('status', 'pending')
->update([
'status' => 'superseded',
'note' => 'Superseded by new command'
]);
$command = RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => $this->userId,
'command_type' => 'update_products',
'payload' => [], // No extra payload needed for B012 trigger
'status' => 'pending',
'remark' => $this->remark ?: __('Batch Product Sync'),
]);
$success = $mqttService->pushCommand(
$machine->serial_no,
'update_products',
[],
(string) $command->id
);
if (!$success) {
$command->update(['status' => 'failed', 'note' => 'MQTT push failed']);
}
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Jobs\Product;
use App\Models\System\Company;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SyncProductsToMachinesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @param int $companyId
* @param string|null $remark
* @param int|null $userId
*/
public function __construct(
protected int $companyId,
protected ?string $remark = null,
protected ?int $userId = null
) {}
/**
* Execute the job.
*
* @return void
*/
public function handle(): void
{
$company = Company::with('machines')->find($this->companyId);
if (!$company || $company->machines->isEmpty()) {
return;
}
$delayMs = 100; // 100ms per machine
foreach ($company->machines as $index => $machine) {
// Calculate delay to avoid thundering herd (100ms, 200ms, 300ms...)
$currentDelay = ($index * $delayMs) / 1000;
SendProductSyncCommandJob::dispatch($machine->id, $this->remark, $this->userId)
->delay(now()->addSeconds($currentDelay));
}
}
}

View File

@ -673,24 +673,6 @@ return [
]
],
],
[
'name' => '指令下發:廣告同步 (Update Ads)',
'slug' => 'mqtt-command-update-ads',
'action' => 'SUB',
'topic' => 'machine/{serial_no}/command',
'qos' => 1,
'description' => '雲端主動下發「廣告同步」指令。指示機台重新拉取 B005 廣告清單。',
'payload_parameters' => [
'command' => ['type' => 'string', 'description' => '固定為 "update_ads"'],
'command_id' => ['type' => 'string', 'description' => '指令唯一 ID'],
'payload' => ['type' => 'object', 'description' => '空物件 (無額外參數)'],
],
'payload_example' => [
'command' => 'update_ads',
'command_id' => '1122334456',
'payload' => new \stdClass()
],
],
[
'name' => '指令下發:遠端找零 (Change)',
'slug' => 'mqtt-command-change',
@ -720,7 +702,7 @@ return [
'qos' => 1,
'description' => '雲端主動下發的系統級別控制指令。包含重啟、設備鎖定解鎖、購物車結帳等無額外參數的指令。',
'payload_parameters' => [
'command' => ['type' => 'string', 'description' => '系統指令類型。支援reboot (重啟機台), reboot_card (重啟刷卡機), checkout (購物車結帳), lock (設備鎖定), unlock (設備解鎖)'],
'command' => ['type' => 'string', 'description' => '系統指令類型。支援reboot (重啟機台), reboot_card (重啟刷卡機), checkout (購物車結帳), lock (設備鎖定), unlock (設備解鎖), update_ads (廣告同步), update_products (商品資料同步)'],
'command_id' => ['type' => 'string', 'description' => '指令唯一 ID'],
'payload' => ['type' => 'object', 'description' => '空物件 (無額外參數)'],
],

View File

@ -168,9 +168,9 @@
"Audit Permissions": "Audit Permissions",
"Authorization updated successfully": "Authorization updated successfully",
"Authorize": "Authorize",
"Authorize Btn": "Authorize Btn",
"Authorize Btn": "Authorize",
"Authorized Accounts": "Authorized Accounts",
"Authorized Accounts Tab": "Authorized Accounts Tab",
"Authorized Accounts Tab": "Authorized Accounts",
"Authorized Machines": "Authorized Machines",
"Authorized Machines Management": "Authorized Machines Management",
"Authorized Status": "Authorized Status",
@ -1592,6 +1592,7 @@
"This is a system administrator role. Its name is locked to ensure system stability.": "This is a system administrator role. Its name is locked to ensure system stability.",
"This role belongs to another company and cannot be assigned.": "This role belongs to another company and cannot be assigned.",
"This slot has a pending command. Please wait.": "This slot has a pending command. Please wait.",
"This machine has a pending command. Please wait.": "This machine has a pending command. Please wait.",
"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 is syncing with the machine. Please wait.": "This slot is syncing with the machine. Please wait.",
"Ticket": "Ticket",
@ -1777,33 +1778,33 @@
"Your recent account activity": "Your recent account activity",
"accounts": "Account Management",
"admin": "Admin",
"analysis": "Analysis Management",
"analysis": "Data Analysis",
"and :count other items": "and :count other items",
"app": "APP Management",
"app": "APP Maintenance",
"audit": "Audit Management",
"basic-settings": "Basic Settings",
"basic.machines": "Machine Settings",
"basic.payment-configs": "Customer Payment Configs",
"basic.payment-configs": "Customer Payment Config",
"by": "by",
"cancel": "Cancel",
"cancel": "cancel",
"command.change": "Change",
"command.checkout": "Checkout",
"command.dispense": "Dispense",
"command.lock": "Lock Machine",
"command.lock": "Lock",
"command.reboot": "Reboot",
"command.reboot_card": "Reboot Card Reader",
"command.reload_stock": "Sync Stock",
"command.unlock": "Unlock",
"companies": "Customer Management",
"completed": "Pickup Completed",
"consume": "Pickup Success",
"consume": "Pickup Successful",
"consume_failed": "Pickup Failed",
"consumed": "Verification Completed",
"consumed": "Consumed",
"create": "Create",
"data-config": "Data Configuration",
"data-config.sub-account-roles": "Sub Account Roles",
"data-config.sub-accounts": "Sub Account Management",
"disabled": "Disabled",
"data-config.sub-accounts": "Sub Accounts",
"disabled": "disabled",
"e.g. 500ml / 300g": "e.g. 500ml / 300g",
"e.g. John Doe": "e.g. John Doe",
"e.g. Main Warehouse": "e.g. Main Warehouse",
@ -1818,40 +1819,40 @@
"e.g., John Doe": "e.g., John Doe",
"e.g., S001": "e.g., S001",
"e.g., Taipei Station": "e.g., Taipei Station",
"e.g., お飲み物": "e.g., お飲み物",
"enabled": "Enabled",
"failed": "Failed",
"e.g., お飲み物": "e.g., Drinks",
"enabled": "enabled",
"failed": "failed",
"files selected": "files selected",
"hours ago": "hours ago",
"image": "Image",
"image": "image",
"items": "items",
"john@example.com": "john@example.com",
"line": "Line Management",
"line": "LINE Config",
"log.command.failed": "Remote command [:type] execution failed",
"log.command.success": "Remote command [:type] execution succeeded",
"log.passcode.verify_success_note": "Maintenance staff verified pass code [:code] at machine [:machine_sn]",
"log.command.success": "Remote command [:type] execution successful",
"log.passcode.verify_success_note": "Maintenance staff verified passcode [:code] on machine [:machine_sn]",
"log.pickup.consume_success": "[Pickup Code] Consumed successfully: :code, Slot: :slot",
"log.pickup.consume_success_note": "Machine [:machine_sn] consumed pickup code. Slot :slot: :old -> :new",
"log.pickup.dispense_failed": "[Pickup Code] Machine reported dispense failed: :code",
"log.pickup.dispense_failed_note": "Machine [:machine_sn] reported pickup code [:code] dispense failed",
"log.pickup.verify_success": "[Pickup Code] Verified successfully, Code: :code",
"log.pickup.dispense_failed_note": "Machine [:machine_sn] reported dispense failed for pickup code [:code]",
"log.pickup.verify_success": "[Pickup Code] Verification successful, Code: :code",
"log.pickup.verify_success_note": "Machine [:machine_sn] verified pickup code [:code]",
"machines": "Machine Management",
"members": "Member Management",
"menu.analysis": "Data Analysis",
"menu.app": "APP Operations",
"menu.app": "APP Maintenance",
"menu.audit": "Audit Management",
"menu.basic": "Basic Settings",
"menu.basic.machines": "Machine Settings",
"menu.basic.payment-configs": "Customer Payment Configs",
"menu.basic.payment-configs": "Customer Payment Config",
"menu.data-config": "Data Configuration",
"menu.data-config.admin-products": "Product Status",
"menu.data-config.advertisements": "Advertisement Management",
"menu.data-config.badges": "Staff ID Management",
"menu.data-config.points": "Points Settings",
"menu.data-config.points": "Point Settings",
"menu.data-config.products": "Product Management",
"menu.data-config.sub-accounts": "Sub Accounts",
"menu.line": "LINE Configuration",
"menu.line": "LINE Config",
"menu.machines": "Machine Management",
"menu.machines.list": "Machine List",
"menu.machines.maintenance": "Maintenance Records",
@ -1862,7 +1863,7 @@
"menu.permissions": "Permission Management",
"menu.permissions.accounts": "Account Management",
"menu.permissions.companies": "Customer Management",
"menu.permissions.roles": "Role Management",
"menu.permissions.roles": "Role Permissions",
"menu.remote": "Remote Commands",
"menu.remote.commands": "Command Center",
"menu.remote.stock": "Remote Expiry & Stock",
@ -1871,7 +1872,7 @@
"menu.sales.orders": "Purchase Orders",
"menu.sales.pass-codes": "Pass Codes",
"menu.sales.pickup-codes": "Pickup Codes",
"menu.sales.promotions": "Promotional Periods",
"menu.sales.promotions": "Promotions",
"menu.sales.records": "Sales Records",
"menu.sales.store-gifts": "Store Gifts",
"menu.special-permission": "Special Permissions",
@ -1882,15 +1883,15 @@
"menu.warehouses.inventory": "Inventory Management",
"menu.warehouses.machine-inventory": "Machine Inventory Overview",
"menu.warehouses.overview": "Warehouse Overview",
"menu.warehouses.replenishments": "Machine Replenishment Orders",
"menu.warehouses.transfers": "Transfer Order Management",
"menu.warehouses.replenishments": "Machine Replenishment",
"menu.warehouses.transfers": "Transfer Orders",
"min": "min",
"mins ago": "mins ago",
"movement.note.initial_sync_report": "B009 Initial sync slot stock (New slot: :slot_no, Initial stock: :new)",
"movement.note.manual_adjustment": "Manual stock adjustment in backend (Old: :old -> New: :new)",
"movement.note.product_changed_and_adjusted": "B009 Slot product changed and stock adjusted (Stock :old -> :new)",
"movement.note.replenishment_correction": "B009 Inventory correction (Old: :old -> New: :new)",
"movement.note.slot_decommissioned_by_b009": "B009 Full sync: Slot :slot_no not in report, stock :old cleared and removed",
"movement.note.initial_sync_report": "B009 Initial sync slot inventory (New slot: :slot_no, Initial stock: :new)",
"movement.note.manual_adjustment": "Backend manual adjustment of slot inventory (Old: :old -> New: :new)",
"movement.note.product_changed_and_adjusted": "B009 Slot product changed and inventory adjusted (Stock :old -> :new)",
"movement.note.replenishment_correction": "B009 Inventory report correction (Old: :old -> New: :new)",
"movement.note.slot_decommissioned_by_b009": "B009 Full sync: Slot :slot_no is not in the report list, stock :old reset to zero and removed",
"movement.note.remote_dispense_queued": "Remote dispense command (B055), Command ID: :id",
"movement.type.adjustment": "Inventory Adjustment",
"movement.type.decommission": "B009 Full Sync Removal",
@ -1900,11 +1901,11 @@
"movement.type.rollback": "Dispense Failed Rollback",
"of": "of",
"of items": "items",
"pending": "Pending Pickup",
"pending": "Pending",
"permissions": "Permission Settings",
"permissions.accounts": "Account Management",
"permissions.companies": "Customer Management",
"permissions.roles": "Role Management",
"permissions.roles": "Role Permissions",
"remote": "Remote Management",
"reservation": "Reservation System",
"roles": "Roles",
@ -1914,23 +1915,23 @@
"set": "Set",
"special-permission": "Special Permissions",
"standby": "Standby Ad",
"success": "Success",
"success": "success",
"superseded": "Superseded",
"timeout": "Timeout",
"super-admin": "Super Admin",
"to": "to",
"update": "Update",
"used": "Used",
"user": "General User",
"user": "User",
"vending": "Vending Page",
"Verified Only": "Verified Only",
"verified": "Verified Successfully",
"verified": "Verified",
"verify_success": "Verify Success",
"video": "Video",
"visit_gift": "Visit Gift",
"vs Yesterday": "vs Yesterday",
"warehouses": "Warehouse Management",
"待填寫": "To be filled",
"待填寫": "Pending",
"Sales Today": "Sales Today",
"Revenue": "Revenue",
"Errors": "Errors",
@ -1945,7 +1946,20 @@
"Real-time fleet status and revenue monitoring": "Real-time fleet status and revenue monitoring",
"[StaffCard] Verification failed: :uid": "[StaffCard] Verification failed: :uid",
"[PickupCode] Verification failed: :code": "[PickupCode] Verification failed: :code",
"Sync to Machine": "Sync to Machine",
"Manual Sync Ads": "Manual Sync Ads",
"Sync command sent successfully.": "Sync command sent successfully.",
"Failed to send sync command.": "Failed to send sync command.",
"Sync Ads": "Sync Ads",
"Uploading...": "Uploading...",
"Upload failed, please try again": "Upload failed, please try again"
"Upload failed, please try again": "Upload failed, please try again",
"Sync to All Machines": "Sync to All Machines",
"Sync Products": "Sync Products",
"Reason for this sync...": "Reason for this sync...",
"Select Target Company": "Select Target Company",
"Search Company...": "Search Company...",
"Manual Sync Products": "Manual Sync Products",
"Please select a company.": "Please select a company.",
"Batch sync command has been queued. Machines will be updated sequentially.": "Batch sync command has been queued. Machines will be updated sequentially.",
"A sync command was recently sent. Please wait 1 minute.": "A sync command was recently sent. Please wait 1 minute."
}

File diff suppressed because it is too large Load Diff

View File

@ -1952,5 +1952,14 @@
"Failed to send sync command.": "無法送出同步指令",
"Sync Ads": "同步廣告",
"Uploading...": "上傳中...",
"Upload failed, please try again": "上傳失敗,請重試"
"Upload failed, please try again": "上傳失敗,請重試",
"Sync to All Machines": "同步到所有機台",
"Sync Products": "同步商品資料",
"Reason for this sync...": "此次同步的原因...",
"Select Target Company": "選擇目標公司",
"Search Company...": "搜尋公司...",
"Manual Sync Products": "手動同步商品",
"Please select a company.": "請選擇公司",
"Batch sync command has been queued. Machines will be updated sequentially.": "批次同步指令已進入隊列,機台將依序進行更新",
"A sync command was recently sent. Please wait 1 minute.": "近期已發送過同步指令,請等待 1 分鐘後再試"
}

View File

@ -25,13 +25,22 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
<x-page-header :title="__('Product Management')"
:subtitle="__('Manage your catalog, prices, and multilingual details.')">
<template x-if="activeTab === 'products'">
<a href="{{ route($baseRoute . '.create') }}"
class="btn-luxury-primary whitespace-nowrap flex items-center gap-2 transition-all duration-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span class="text-sm sm:text-base">{{ __('Add Product') }}</span>
</a>
<div class="flex items-center gap-2 sm:gap-3">
<button @click="syncToAllMachines()"
class="btn-luxury-ghost group whitespace-nowrap flex items-center gap-2 transition-all duration-300 py-2 sm:py-2.5 px-4 sm:px-6 rounded-2xl border-slate-200 dark:border-white/10 hover:border-cyan-500/50 hover:bg-cyan-500/5">
<svg class="w-4 h-4 text-cyan-500 group-hover:rotate-180 transition-transform duration-700" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span class="text-sm font-black tracking-tight uppercase">{{ __('Sync to All Machines') }}</span>
</button>
<a href="{{ route($baseRoute . '.create') }}"
class="btn-luxury-primary whitespace-nowrap flex items-center gap-2 transition-all duration-300 shadow-lg shadow-emerald-500/10">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span class="text-sm sm:text-base font-black tracking-tight uppercase">{{ __('Add Product') }}</span>
</a>
</div>
</template>
<template x-if="activeTab === 'categories'">
<button @click="openCategoryModal()" type="button"
@ -459,6 +468,98 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
</div>
</div>
</div>
<!-- Custom Confirmation Modal (Mirroring Remote Control) -->
<template x-teleport="body">
<div x-show="confirmModal.show" class="fixed inset-0 z-[120] overflow-y-auto" x-cloak>
<div class="flex min-h-screen items-center justify-center p-4 text-center sm:p-0">
<!-- Background Backdrop -->
<div x-show="confirmModal.show" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
@click="confirmModal.show = false"></div>
<!-- Modal Content -->
<div x-show="confirmModal.show" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="relative transform overflow-hidden rounded-[2.5rem] bg-white dark:bg-slate-900 p-8 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-lg border border-slate-200 dark:border-slate-800">
<div class="flex items-center gap-4 mb-6">
<div
class="w-14 h-14 rounded-2xl bg-amber-500/10 flex items-center justify-center text-amber-500 border border-amber-500/20">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<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>
</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-sm font-black text-slate-800 dark:text-slate-200"
x-text="getCommandName(confirmModal.type)"></span>
</div>
<div class="flex justify-between items-center px-1 pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
<span class="text-[10px] font-black text-cyan-500 uppercase tracking-widest">{{ __('Target') }}</span>
<span class="text-sm font-black text-slate-800 dark:text-slate-200"
x-text="confirmModal.company_id ? (companies.find(c => c.id == confirmModal.company_id)?.name || '{{ __('All Machines') }}') : '{{ __('All Machines') }}'"></span>
</div>
@if(auth()->user()->isSystemAdmin())
<div class="space-y-2 px-1 pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
<label class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.2em] ml-1">
{{ __('Select Target Company') }}
</label>
<div id="sync_company_select_wrapper" class="relative">
<!-- Searchable Select will be injected here -->
</div>
</div>
@endif
<div class="space-y-2 px-1 pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
<label
class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.2em] ml-1">{{
__('Operation Note') }}</label>
<textarea x-model="confirmModal.note"
class="luxury-input w-full min-h-[100px] text-sm py-3 px-4 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus:border-cyan-500/50"
placeholder="{{ __('Reason for this sync...') }}"></textarea>
</div>
</div>
<div class="flex gap-4">
<button @click="confirmModal.show = false"
class="flex-1 px-6 py-4 rounded-2xl bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 text-xs font-black uppercase tracking-widest hover:bg-slate-200 dark:hover:bg-slate-700 transition-all">
{{ __('Cancel') }}
</button>
<button @click="executeSyncToAll()" :disabled="loading"
class="flex-1 px-6 py-4 rounded-2xl bg-cyan-600 text-white text-xs font-black uppercase tracking-widest hover:bg-cyan-500 shadow-lg shadow-cyan-500/20 active:scale-[0.98] transition-all disabled:opacity-50">
<span x-show="!loading">{{ __('Execute') }}</span>
<span x-show="loading" class="flex items-center justify-center gap-2">
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ __('Processing...') }}
</span>
</button>
</div>
</div>
</div>
</div>
</template>
</div>
@endsection
@ -472,6 +573,12 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
isStatusConfirmOpen: false,
isDeleteConfirmOpen: false,
isCategoryModalOpen: false,
confirmModal: {
show: false,
type: 'sync_products',
note: '',
company_id: ''
},
activeTab: '{{ request("tab", "products") }}',
tabLoading: null,
loading: false,
@ -490,11 +597,26 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
init() {
this.categories = JSON.parse(this.$el.dataset.categories || '[]');
this.companies = @js($companies);
this.translations = @js([
'Sync Products' => __('Sync Products'),
'Connection error' => __('Connection error'),
'Failed to send command' => __('Failed to send command'),
'Select Target Company' => __('Select Target Company'),
'Search Company...' => __('Search Company...'),
]);
// Initial binding
this.bindPaginationLinks('#tab-products-container', 'products');
this.bindPaginationLinks('#tab-categories-container', 'categories');
this.$watch('confirmModal.show', (value) => {
if (value && document.getElementById('sync_company_select_wrapper')) {
this.$nextTick(() => {
this.updateSyncCompanySelect();
});
}
});
// Watch for category modal updates
this.$watch('isCategoryModalOpen', (value) => {
if (value && document.getElementById('category_company_select_wrapper')) {
@ -788,6 +910,108 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString();
},
syncToAllMachines() {
this.confirmModal.note = '';
this.confirmModal.company_id = '{{ auth()->user()->company_id }}';
this.confirmModal.show = true;
},
updateSyncCompanySelect() {
const wrapper = document.getElementById('sync_company_select_wrapper');
if (!wrapper) return;
wrapper.innerHTML = '';
const selectEl = document.createElement('select');
selectEl.name = 'sync_company_id';
const uniqueId = 'sync-company-' + Date.now();
selectEl.id = uniqueId;
selectEl.className = 'hidden';
const config = {
"placeholder": this.translations['Select Target Company'],
"hasSearch": true,
"searchPlaceholder": this.translations['Search Company...'],
"isHidePlaceholder": false,
"searchClasses": "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500",
"searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
"toggleClasses": "hs-select-toggle luxury-select-toggle",
"dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[100] animate-luxury-in",
"optionClasses": "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between transition-all duration-300",
"optionTemplate": '<div class="flex items-center justify-between w-full"><span data-title></span><span class="hs-select-active-indicator hidden text-cyan-500"><svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></span></div>'
};
selectEl.setAttribute('data-hs-select', JSON.stringify(config));
this.companies.forEach((company, index) => {
const opt = document.createElement('option');
opt.value = company.id;
opt.textContent = company.name;
opt.setAttribute('data-title', company.name);
// 如果目前尚未選擇公司 (例如系統管理員初次點開),則自動預設選中第一個
if (!this.confirmModal.company_id && index === 0) {
this.confirmModal.company_id = company.id;
opt.selected = true;
} else if (String(company.id) === String(this.confirmModal.company_id)) {
opt.selected = true;
}
selectEl.appendChild(opt);
});
selectEl.addEventListener('change', (e) => {
this.confirmModal.company_id = e.target.value;
});
wrapper.appendChild(selectEl);
// Initialize Preline Select
this.$nextTick(() => {
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
window.HSStaticMethods.autoInit();
}
});
},
getCommandName(type) {
const names = {
'sync_products': this.translations['Sync Products']
};
return names[type] || type;
},
async executeSyncToAll() {
this.loading = true;
this.confirmModal.show = false;
try {
const response = await fetch('{{ route($baseRoute . ".sync-all") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify({
remark: this.confirmModal.note,
company_id: this.confirmModal.company_id
})
});
const data = await response.json();
if (data.success) {
if (window.showToast) window.showToast(data.message, 'success');
this.confirmModal.note = '';
} else {
if (window.showToast) window.showToast(data.message || this.translations['Failed to send command'], 'error');
}
} catch (error) {
console.error('Sync error:', error);
if (window.showToast) window.showToast(this.translations['Connection error'], 'error');
} finally {
this.loading = false;
}
}
}));
});

View File

@ -42,6 +42,7 @@
'Remote Dispense' => __('Remote Dispense'),
'Adjust Stock & Expiry' => __('Adjust Stock & Expiry'),
'Sync Ads' => __('Sync Ads'),
'Sync Products' => __('Sync Products'),
'Pending' => __('Pending'),
'Sent' => __('Sent'),
'Success' => __('Success'),
@ -443,7 +444,8 @@
'change': this.translations['Remote Change'],
'dispense': this.translations['Remote Dispense'],
'reload_stock': this.translations['Adjust Stock & Expiry'],
'update_ads': this.translations['Sync Ads']
'update_ads': this.translations['Sync Ads'],
'update_products': this.translations['Sync Products']
};
return names[type] || type;
},

View File

@ -84,6 +84,7 @@
'change' => __('Remote Change'),
'dispense' => __('Remote Dispense'),
'update_ads' => __('Sync Ads'),
'update_products' => __('Sync Products'),
]" :selected="request('command_type')" :placeholder="__('All Command Types')"
:hasSearch="false"
@change="searchInTab('history')" />
@ -209,7 +210,7 @@
<td class="px-6 py-6">
<div class="flex flex-col min-w-[200px]">
@if($item->remark)
<span class="text-sm font-bold text-slate-600 dark:text-slate-300 break-words leading-tight">{{ $item->remark }}</span>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300 break-words leading-tight">{{ __($item->remark) }}</span>
@endif
@if($item->note)
@ -330,7 +331,7 @@
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Operation Note') }}</p>
<div class="flex flex-col gap-1.5 p-3 rounded-2xl bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800/50">
@if($item->remark)
<span class="text-sm font-bold text-slate-700 dark:text-slate-200 leading-tight">{{ $item->remark }}</span>
<span class="text-sm font-bold text-slate-700 dark:text-slate-200 leading-tight">{{ __($item->remark) }}</span>
@endif
@if($item->note)
<div class="flex items-center gap-1.5 opacity-60">

View File

@ -163,6 +163,7 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
Route::middleware('can:menu.data-config.products')->group(function () {
Route::resource('products', App\Http\Controllers\Admin\ProductController::class)->except(['show']);
Route::patch('/products/{id}/toggle-status', [App\Http\Controllers\Admin\ProductController::class, 'toggleStatus'])->name('products.status.toggle');
Route::post('/products/sync-all', [App\Http\Controllers\Admin\ProductController::class, 'syncToAllMachines'])->name('products.sync-all');
Route::resource('product-categories', App\Http\Controllers\Admin\ProductCategoryController::class)->except(['show', 'create', 'edit']);
});