[FEAT] 商品與機台多語系架構,並補上系統設定同步操作

1. 新增 config/locales.php:定義 11 種系統支援語系白名單(原文+中文註記)、每台機台上限 5 種與 fallback (zh_TW)。
2. Company 新增 activeLocalesFor()/activeLocales():計算公司所有機台已開語系的聯集(去重、依白名單排序、保底 zh_TW),加快取與 forgetActiveLocales() 失效機制。
3. products 新增 spec_dictionary_key 欄位(migration);Product 新增 localized_spec accessor 與 specTranslations 關聯,商品規格比照名稱支援多語系。
4. ProductController 商品建立/更新改收 names 與 specs 物件,寫入 translations(group=product/product_spec),既有商品首次編輯補建 spec key,刪除時一併清除規格翻譯。
5. ProductCatalogService(B012)保留既有 t060v01_en/_jp 欄位,新增 t060v01_i18n/t060v03_i18n locale map,依公司語系聯集輸出並回退 zh_TW,確保線上舊 App 相容。
6. 機台系統設定(updateSystemSettings)新增 languages 顯示語系驗證與寫入(最多 5 種、白名單、去重,僅系統管理員),異動時失效語系聯集快取並重建商品目錄。
7. B014(getSettings)新增 LangSet 下發機台顯示語系(Languages 有序清單+Default 預設)。
8. 商品建立/編輯頁名稱與規格改為多語系 Tab(讀公司機台語系聯集、完成度標記),新增共用元件 product-locale-tabs;機台系統設定彈窗新增顯示語系勾選 UI(限 5 選、預設標記)。
9. 三語系 JSON(zh_TW/en/ja)新增 5 組對齊鍵並維持字母排序。
10. 同步更新文件:docs/API/product_multilingual_spec.md(規格書)、api-technical-specs SKILL.md(B012 i18n/B014 LangSet)、config/api-docs.php(B012/B014 範例與欄位)。
11. 系統設定同步:新增 sync-settings 路由與 update_settings 指令類型,ProcessCommandAck 與遠端指令歷史(remote index/tab-history)支援 update_settings 標籤,機台系統設定頁新增「同步設定」操作按鈕,並補 B016 回寫測試。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sky121113 2026-06-15 09:00:10 +08:00
parent a5e5390cd1
commit 779c59138f
23 changed files with 1088 additions and 196 deletions

View File

@ -137,10 +137,12 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| 欄位 | 型別 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| t060v00 | String | 商品資料庫 ID | "1" |
| t060v01 | String | 商品名稱 | 可口可樂 330ml |
| t060v01_en | String | 英文名稱 | Coca Cola |
| t060v01_jp | String | 日文名稱 | コカコーラ |
| t060v03 | String | 商品規格/簡述 | Cold Drink |
| t060v01 | String | 商品名稱(預設語系 zh_TW | 可口可樂 330ml |
| t060v01_en | String | 英文名稱(**既有欄位,保留相容** | Coca Cola |
| t060v01_jp | String | 日文名稱(**既有欄位,保留相容** | コカコーラ |
| t060v01_i18n | Object | **【新增】名稱多語系 map**locale → 名稱key 集合 = 公司機台語系聯集;缺翻譯回退 zh_TW | {"zh_TW":"可口可樂","en":"Coca Cola","ja":"コカコーラ"} |
| t060v03 | String | 商品規格/簡述(預設語系 zh_TW | Cold Drink |
| t060v03_i18n | Object | **【新增】規格多語系 map**locale → 規格),同 `t060v01_i18n` 邏輯 | {"zh_TW":"330ml","en":"330ml","ja":"330ml"} |
| t060v06 | String | 圖片 URL | https://.../coke.png |
| t060v09 | Float | 標準零售價 | 25.0 |
| t060v11 | Integer | **貨道庫存上限** (預設履帶) | 10 |
@ -151,6 +153,12 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| spring_limit | Integer | **彈簧貨道上限** (建議使用此欄位) | 10 |
| track_limit | Integer | **履帶貨道上限** (建議使用此欄位) | 15 |
> **多語系 (i18n) 說明**
> - **向下相容鐵則**`t060v01` / `t060v01_en` / `t060v01_jp` / `t060v03` 等既有欄位一律**保留**,本次僅**新增** `t060v01_i18n`、`t060v03_i18n` 兩個 map。線上舊 App 忽略新欄位、照舊運作。
> - **`*_i18n` 的 key 集合 = 公司所有機台已開語系的聯集**(後台於機台系統設定勾選,最多 5 種/台;可選池子見 `config/locales.php`)。缺翻譯時回退 zh_TW不下發空字串。
> - **App 改版方向**:新版 App 改讀 `t060v01_i18n`/`t060v03_i18n` 以支援多語切換,並依 B014 的 `LangSet` 決定要顯示哪些語系。改版前現行 App 僅顯示 zh_TW/en/ja。
> - 完整設計見 [商品多語系與機台顯示語系技術規範](file:///home/mama/projects/star-cloud/docs/API/product_multilingual_spec.md)。
---
### 3.6 B013: [DEPRECATED / REMOVED] 機台故障與異常狀態上報
@ -195,6 +203,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| **CashSet** | 現金面額旗標物件(對齊 `CashSetStructure`| machines.settings → `BillF1000`/`BillE500`/`BillD100`/`CoinF50`/`CoinE10`/`CoinD5`/`CoinC1` |
| **FunctionSet** | 非支付功能模組旗標物件(**新定義**,機台待實作)| machines.settings → `PickupModule`(pickup_module_enabled)/`PickupCode`(pickup_code_enabled)/`PassCode`(pass_code_enabled)/`WelcomeGift`(welcome_gift_enabled)/`MemberSystem`(member_system_enabled)/`AmbientTemp`(ambient_temp_monitoring_enabled) |
| **ShoppingMode** | 購物方式(字串,**新定義**| machines.settings.shopping_mode`basic` / `employee_card` / `pickup_sheet` |
| **LangSet** | 機台顯示語系(**新定義**,最多 5 種)| machines.settings.languages → `Languages`(有序語系陣列,如 `["zh_TW","en","ja"]`,順序即切換順序)`Default`(預設語系=清單第一個)。未設定時退化為 `[fallback]`。機台據此渲染語系切換 UI。可選語系池子見 `config/locales.php` |
| **OperationSet** | 運作參數(**新定義**,來源 machines 實體欄位)| `CardReaderSeconds`(card_reader_seconds 整數)/`PaymentBufferSeconds`(payment_buffer_seconds 整數)/`CheckoutTime1`(card_reader_checkout_time_1 時間字串)/`CheckoutTime2`(card_reader_checkout_time_2)/`HeatingStartTime`(heating_start_time)/`HeatingEndTime`(heating_end_time) |
| **HardwareSet** | 硬體與貨道(**新定義**,來源 machines 實體欄位)| `CardReaderNo`(card_reader_no 字串)/`SpringSlot1_10`~`SpringSlot51_60`(is_spring_slot_* 布林true=彈簧 / false=履帶) |
@ -205,7 +214,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
---
### 3.8 B016: 機台系統設定回寫 (Settings Write-back)
機台主控台「系統設定」頁由**系統方**編輯後,將開關狀態回寫雲端。為 B014 下載的反向操作。
機台主控台由**系統方**設定硬體貨道類型後,將貨道開關狀態回寫雲端。為 B014 下載的反向操作。
- **URL**: PATCH /api/v1/app/machine/setting/B016
- **Authentication**: **User Token (Sanctum)** — 帶入 B000 核發之 `Authorization: Bearer <user_token>`,且**僅系統管理員 (`identity=system`)** 可操作;非系統方一律 403。
@ -217,16 +226,12 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台序號 (serial_no) | SN202604130001 |
| settings | Object | 是 | 開關鍵值物件,鍵名採**後端原始 snake_case**(非 B014 的 PascalCase `*Set`。只覆寫有送來的鍵,其餘保留 | {"tax_invoice_enabled": true} |
| settings | Object | 是 | 貨道類型鍵值物件,鍵名採**後端原始 snake_case**。只覆寫有送來的鍵,其餘保留 | {"is_spring_slot_1_10": true} |
> [!IMPORTANT]
> **命名方向不對稱**B014 **下載**以 `DevSet`/`CashSet`/... PascalCase 包裝下發B016 **回寫**直接使用後端鍵名(如 `shopping_cart_enabled`、`cash_bill_1000`、`is_spring_slot_1_10`)。
> **白名單限定**:僅接受下列布林開關鍵,未列入者一律忽略;**不接受** `shopping_mode``OperationSet` 等非開關欄位(避免誤改)。
> **僅硬體貨道**:機台端 B016 現在**只回寫硬體貨道類型** (`is_spring_slot_*`)。支付旗標、現金面額、功能模組等系統設定純由**後台單向決定**B016 **不再接收**,未列入下列白名單者一律忽略。
- **可回寫鍵settings 之下,皆為布林):**
- 支付旗標:`shopping_cart_enabled`、`tax_invoice_enabled`、`card_terminal_enabled`、`credit_card_enabled`、`mobile_pay_enabled`、`card_pay_enabled`、`scan_pay_enabled`、`scan_pay_esun_enabled`、`scan_pay_tappay_enabled`、`cash_module_enabled`、`scan_pay_linepay_enabled`、`tappay_linepay`、`tappay_jkopay`、`tappay_easywallet`、`tappay_pipay`、`tappay_pluspay`
- 現金面額:`cash_bill_1000`、`cash_bill_500`、`cash_bill_100`、`cash_coin_50`、`cash_coin_10`、`cash_coin_5`、`cash_coin_1`
- 功能模組:`pickup_module_enabled`、`pickup_code_enabled`、`pass_code_enabled`、`welcome_gift_enabled`、`member_system_enabled`、`ambient_temp_monitoring_enabled`
- 貨道類型machines 實體欄位):`is_spring_slot_1_10`、`is_spring_slot_11_20`、`is_spring_slot_21_30`、`is_spring_slot_31_40`、`is_spring_slot_41_50`、`is_spring_slot_51_60`true=彈簧 / false=履帶)
- **Response Body (Success 200):**
@ -243,9 +248,9 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| :--- | :--- | :--- |
| 403 | 非系統管理員 / 未登入 | Forbidden |
| 404 | 查無該序號機台 | Machine not found |
| 422 | `settings` 非物件/陣列 | Invalid settings payload |
| 422 | `settings` 非物件/陣列,或未含任何有效貨道鍵 | Invalid settings payload |
> 註:寫回時同步更新 `machines.settings` JSON 與 `is_spring_slot_*` 實體欄位並記錄 `updater_id`。為 system-admin 專用,套用 `withoutGlobalScopes` 跨租戶定位機台。
> 註:寫回時更新 `is_spring_slot_*` 實體欄位並記錄 `updater_id`。為 system-admin 專用,套用 `withoutGlobalScopes` 跨租戶定位機台。
---

View File

@ -129,13 +129,17 @@ class MachineSettingController extends AdminController
$paymentConfigs = PaymentConfig::select('id', 'name')->get();
$companies = Company::select('id', 'name', 'code')->orderBy('name')->get();
// 同步系統設定彈窗的機台選擇器清單 (依 TenantScoped 自動租戶隔離)
$allMachines = Machine::select('id', 'name', 'serial_no')->orderBy('name')->get();
return view('admin.basic-settings.machines.index', compact(
'machines',
'models_list',
'machines',
'models_list',
'users_list',
'models',
'paymentConfigs',
'models',
'paymentConfigs',
'companies',
'allMachines',
'tab'
));
}
@ -331,6 +335,17 @@ class MachineSettingController extends AdminController
]);
}
/**
* 系統設定錯誤回應:依請求型態回 JSON redirect。
*/
private function settingsError(Request $request, string $message, int $code): \Illuminate\Http\JsonResponse|RedirectResponse
{
if ($request->expectsJson()) {
return response()->json(['success' => false, 'message' => $message], $code);
}
return redirect()->back()->with('error', $message)->withInput();
}
public function updateSystemSettings(Request $request, Machine $machine): \Illuminate\Http\JsonResponse|RedirectResponse
{
\Log::info('Update System Settings Request:', $request->all());
@ -419,6 +434,28 @@ class MachineSettingController extends AdminController
$jsonSettings[$field] = $data[$field];
}
// 顯示語系 (languages):機台螢幕可切換的語系,最多 N 種,僅系統管理員可設定。
// 商品翻譯範圍由「公司機台語系聯集」反推,故此處異動需失效聯集快取並重建商品目錄。
// 非系統管理員即使送出 languages 亦一律忽略(不報錯,讓其仍可儲存其他設定)。
$languagesChanged = false;
if (array_key_exists('languages', $settings) && auth()->user()->isSystemAdmin()) {
$whitelist = array_keys(config('locales.supported', []));
$max = (int) config('locales.max_per_machine', 5);
// 去重、保留順序、僅留白名單內語系
$languages = array_values(array_unique(array_filter(
(array) $settings['languages'],
fn ($l) => in_array($l, $whitelist, true)
)));
if (count((array) $settings['languages']) > $max || count($languages) > $max) {
return $this->settingsError($request, __('You can select at most :max languages.', ['max' => $max]), 422);
}
$jsonSettings['languages'] = $languages;
$languagesChanged = true;
}
$machine->update([
'settings' => $jsonSettings,
'updater_id' => auth()->id()
@ -427,6 +464,12 @@ class MachineSettingController extends AdminController
// 同時更新 model 的屬性欄位 (以防 mutator 同步,以及確保 dirty check 正確)
$machine->update(array_merge($data, ['updater_id' => auth()->id()]));
// 語系異動:失效公司語系聯集快取並重建商品目錄 i18nB012 下發內容隨之更新)
if ($languagesChanged) {
\App\Models\System\Company::forgetActiveLocales($machine->company_id);
app(\App\Services\Product\ProductCatalogService::class)->rebuildCache($machine->company_id);
}
if ($request->expectsJson()) {
return response()->json([
'success' => true,
@ -463,6 +506,67 @@ class MachineSettingController extends AdminController
]);
}
/**
* 同步系統設定至機台:透過 MQTT 下發 update_settings 指令,
* 通知機台 APP 主動回抓最新系統設定 (B014)
* 參考廣告同步 (AdvertisementController::syncToMachine) 的併行控制模式。
*/
public function syncSettings(Request $request, Machine $machine, \App\Services\Machine\MqttService $mqttService): \Illuminate\Http\JsonResponse
{
// 併行檢查:若有 pending 指令,超過 1 分鐘視為逾時可覆蓋
$pendingCommand = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'update_settings')
->where('status', 'pending')
->latest()
->first();
if ($pendingCommand) {
$isTimedOut = $pendingCommand->created_at->lt(now()->subMinute());
if (!$isTimedOut) {
return response()->json([
'success' => false,
'message' => __('This machine has a pending command. Please wait.')
], 422);
}
$pendingCommand->update([
'status' => 'timeout',
'note' => __('Superseded by new command (Timeout)'),
]);
}
$command = \App\Models\Machine\RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => auth()->id(),
'command_type' => 'update_settings',
'payload' => [],
'status' => 'pending',
'remark' => $request->filled('note') ? $request->input('note') : __('Manual Sync Settings'),
]);
$success = $mqttService->pushCommand(
$machine->serial_no,
'update_settings',
[],
(string) $command->id
);
if ($success) {
return response()->json([
'success' => true,
'message' => __('Sync command sent successfully.')
]);
}
$command->update(['status' => 'failed', 'note' => 'MQTT push failed']);
return response()->json([
'success' => false,
'message' => __('Failed to send sync command.')
], 500);
}
/**
* 刪除機台 (僅限系統管理員)
*/

View File

@ -24,6 +24,51 @@ class ProductController extends Controller
private readonly ProductCatalogService $catalogService
) {}
/**
* Upsert a locale=>value map into translations for a given group/key.
* 空值的語系會被刪除(清空翻譯)。繞過 TenantScoped。
*
* @param string $group product / product_spec
* @param string $key 對應的 dictionary key
* @param array $values locale => value
* @param int|null $companyId
*/
private function upsertTranslations(string $group, string $key, array $values, ?int $companyId): void
{
foreach ($values as $locale => $value) {
if (empty($value)) {
Translation::withoutGlobalScopes()->where([
'group' => $group,
'key' => $key,
'locale' => $locale,
])->delete();
continue;
}
Translation::withoutGlobalScopes()->updateOrCreate(
['group' => $group, 'key' => $key, 'locale' => $locale],
['value' => $value, 'company_id' => $companyId]
);
}
}
/**
* 解析規格多語系輸入:優先採 specs 物件;
* 若僅送舊版單一 spec 欄位則轉為 ['zh_TW' => spec] 以維持相容。
*
* @return array locale => value
*/
private function resolveSpecs(Request $request): array
{
$specs = $request->input('specs');
if (is_array($specs) && !empty($specs)) {
return $specs;
}
$spec = $request->input('spec');
return $spec !== null && $spec !== '' ? ['zh_TW' => $spec] : [];
}
public function index(Request $request)
{
$user = auth()->user();
@ -183,6 +228,9 @@ class ProductController extends Controller
'categories' => $categories,
'companies' => $companies,
'companySettings' => $companySettings,
// 商品多語系 Tab公司機台語系聯集見 product_multilingual_spec §4.2
'locales' => Company::activeLocalesFor($companyId),
'localeLabels' => config('locales.supported', ['zh_TW' => '繁體中文']),
]);
}
@ -191,12 +239,18 @@ class ProductController extends Controller
$user = auth()->user();
// 繞過 TenantScoped 載入翻譯,確保系統管理員能看到租戶公司的翻譯資料
$product = Product::with(['company'])->findOrFail($id);
$product->setRelation('translations',
$product->setRelation('translations',
Translation::withoutGlobalScopes()
->where('group', 'product')
->where('key', $product->name_dictionary_key)
->get()
);
$product->setRelation('specTranslations',
Translation::withoutGlobalScopes()
->where('group', 'product_spec')
->where('key', $product->spec_dictionary_key)
->get()
);
$categories = ProductCategory::with('translations')->get();
$companies = $user->isSystemAdmin() ? Company::all() : collect();
@ -214,9 +268,11 @@ class ProductController extends Controller
public function store(Request $request)
{
$validated = $request->validate([
'names' => 'required|array',
'names.zh_TW' => 'required|string|max:255',
'names.en' => 'nullable|string|max:255',
'names.ja' => 'nullable|string|max:255',
'names.*' => 'nullable|string|max:255',
'specs' => 'nullable|array',
'specs.*' => 'nullable|string|max:255',
'barcode' => 'nullable|string|max:100',
'spec' => 'nullable|string|max:255',
'category_id' => 'nullable|exists:product_categories,id',
@ -236,24 +292,18 @@ class ProductController extends Controller
DB::beginTransaction();
$dictKey = \Illuminate\Support\Str::uuid()->toString();
$specDictKey = \Illuminate\Support\Str::uuid()->toString();
// Determine company_id: prioritized from request (for sys admin) then from user
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
? $request->company_id
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
? $request->company_id
: auth()->user()->company_id;
// 儲存多語系翻譯(繞過 TenantScoped避免系統管理員操作租戶資料時被過濾
foreach ($request->names as $locale => $name) {
if (empty($name)) continue;
Translation::withoutGlobalScopes()->create([
'group' => 'product',
'key' => $dictKey,
'locale' => $locale,
'value' => $name,
'company_id' => $company_id,
]);
}
// 儲存名稱多語系翻譯(繞過 TenantScoped避免系統管理員操作租戶資料時被過濾
$this->upsertTranslations('product', $dictKey, $request->input('names', []), $company_id);
// 儲存規格多語系翻譯與名稱同邏輯group=product_spec
$specs = $this->resolveSpecs($request);
$this->upsertTranslations('product_spec', $specDictKey, $specs, $company_id);
$imageUrl = null;
if ($request->hasFile('image')) {
@ -268,7 +318,8 @@ class ProductController extends Controller
'name_dictionary_key' => $dictKey,
'image_url' => $imageUrl,
'barcode' => $request->barcode,
'spec' => $request->spec,
'spec' => $specs['zh_TW'] ?? '',
'spec_dictionary_key' => $specDictKey,
'manufacturer' => $request->manufacturer,
'track_limit' => $request->track_limit,
'spring_limit' => $request->spring_limit,
@ -319,9 +370,11 @@ class ProductController extends Controller
$product = Product::findOrFail($id);
$validated = $request->validate([
'names' => 'required|array',
'names.zh_TW' => 'required|string|max:255',
'names.en' => 'nullable|string|max:255',
'names.ja' => 'nullable|string|max:255',
'names.*' => 'nullable|string|max:255',
'specs' => 'nullable|array',
'specs.*' => 'nullable|string|max:255',
'barcode' => 'nullable|string|max:100',
'spec' => 'nullable|string|max:255',
'category_id' => 'nullable|exists:product_categories,id',
@ -341,34 +394,19 @@ class ProductController extends Controller
DB::beginTransaction();
$dictKey = $product->name_dictionary_key ?: \Illuminate\Support\Str::uuid()->toString();
// 既有商品若尚無 spec 字典鍵,首次編輯時補建
$specDictKey = $product->spec_dictionary_key ?: \Illuminate\Support\Str::uuid()->toString();
// Determine company_id: prioritized from request (for sys admin) then from product's current company
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
? $request->company_id
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
? $request->company_id
: $product->company_id;
// 更新或建立多語系翻譯(繞過 TenantScoped避免系統管理員操作租戶資料時被過濾
foreach ($request->names as $locale => $name) {
if (empty($name)) {
Translation::withoutGlobalScopes()->where([
'group' => 'product',
'key' => $dictKey,
'locale' => $locale
])->delete();
continue;
}
// 更新或建立名稱多語系翻譯(繞過 TenantScoped避免系統管理員操作租戶資料時被過濾
$this->upsertTranslations('product', $dictKey, $request->input('names', []), $company_id);
Translation::withoutGlobalScopes()->updateOrCreate(
[
'group' => 'product',
'key' => $dictKey,
'locale' => $locale,
],
[
'value' => $name,
'company_id' => $company_id,
]
);
}
// 更新或建立規格多語系翻譯與名稱同邏輯group=product_spec
$specs = $this->resolveSpecs($request);
$this->upsertTranslations('product_spec', $specDictKey, $specs, $company_id);
$data = [
'company_id' => $company_id,
@ -376,7 +414,8 @@ class ProductController extends Controller
'name' => $request->names['zh_TW'] ?? ($product->name ?? 'Untitled'),
'name_dictionary_key' => $dictKey,
'barcode' => $request->barcode,
'spec' => $request->spec,
'spec' => $specs['zh_TW'] ?? ($product->spec ?? ''),
'spec_dictionary_key' => $specDictKey,
'manufacturer' => $request->manufacturer,
'track_limit' => $request->track_limit,
'spring_limit' => $request->spring_limit,
@ -494,10 +533,13 @@ class ProductController extends Controller
try {
$product = Product::findOrFail($id);
// 刪除與此商品關聯的翻譯資料(繞過 TenantScoped
// 刪除與此商品關聯的翻譯資料(名稱 + 規格,繞過 TenantScoped
if ($product->name_dictionary_key) {
Translation::withoutGlobalScopes()->where('key', $product->name_dictionary_key)->delete();
}
if ($product->spec_dictionary_key) {
Translation::withoutGlobalScopes()->where('key', $product->spec_dictionary_key)->delete();
}
// Delete image
if ($product->image_url) {

View File

@ -506,6 +506,17 @@ class MachineController extends Controller
// 5-4 ShoppingMode購物方式頂層字串
$data['ShoppingMode'] = (string) ($s['shopping_mode'] ?? 'basic'); // basic / employee_card / pickup_sheet
// 5-4b LangSet機台顯示語系後台勾選最多 N 種)。機台據此渲染語系切換 UI
// Default 為開機/idle 預設語系(清單第一個)。未設定時退化為 fallback 單語。
$langs = array_values(array_filter((array) ($s['languages'] ?? [])));
if (empty($langs)) {
$langs = [config('locales.fallback', 'zh_TW')];
}
$data['LangSet'] = [
'Languages' => $langs, // 有序清單,順序即切換順序
'Default' => $langs[0], // 預設語系(清單第一個)
];
// 5-5 OperationSet運作參數machines 實體欄位)
$data['OperationSet'] = [
'CardReaderSeconds' => (int) ($machine->card_reader_seconds ?? 0), // 刷卡機秒數
@ -538,9 +549,9 @@ class MachineController extends Controller
* B016: Update Machine System Settings (Write-back from machine console)
* 機台主控台「系統設定」由系統方 (identity=system) 編輯後回寫雲端。
* 認證B000 核發的使用者 Token (auth:sanctum);僅系統管理員可操作。
* Body: { machine: 序號, settings: { <後端鍵名>: bool, ... } }
* 接收的開關鍵名與 B014 getSettings 下發來源一致machines.settings JSON
* is_spring_slot_* 實體欄位)。不處理 shopping_mode 等非開關欄位,避免誤改
* Body: { machine: 序號, settings: { is_spring_slot_*: bool, ... } }
* 機台端僅回寫硬體貨道類型 (is_spring_slot_*) 實體欄位;其餘支付旗標、
* 現金面額、功能模組等開關純由後台決定B016 不接收,未列入者一律忽略
*/
public function updateSettings(Request $request)
{
@ -577,52 +588,30 @@ class MachineController extends Controller
], 422);
}
// 3. 開關白名單machines.settings JSON 內的布林鍵(對齊 B014 getSettings 來源)
$jsonBoolKeys = [
'shopping_cart_enabled', 'tax_invoice_enabled', 'card_terminal_enabled',
'credit_card_enabled', 'mobile_pay_enabled', 'card_pay_enabled', 'scan_pay_enabled',
'scan_pay_esun_enabled', 'scan_pay_tappay_enabled', 'cash_module_enabled', 'scan_pay_linepay_enabled',
'tappay_linepay', 'tappay_jkopay', 'tappay_easywallet', 'tappay_pipay', 'tappay_pluspay',
'cash_bill_1000', 'cash_bill_500', 'cash_bill_100',
'cash_coin_50', 'cash_coin_10', 'cash_coin_5', 'cash_coin_1',
'pickup_module_enabled', 'pickup_code_enabled', 'pass_code_enabled',
'welcome_gift_enabled', 'member_system_enabled', 'ambient_temp_monitoring_enabled',
];
// 直接欄位白名單machines 實體布林欄位(貨道類型)
// 3. 直接欄位白名單machines 實體布林欄位(硬體貨道類型)
// 機台端 B016 僅回寫貨道類型,其餘系統設定由後台單向決定,不在此處接收。
$columnBoolKeys = [
'is_spring_slot_1_10', 'is_spring_slot_11_20', 'is_spring_slot_21_30',
'is_spring_slot_31_40', 'is_spring_slot_41_50', 'is_spring_slot_51_60',
];
// 同時具備實體欄位的 settings 鍵mutator 同步,需一併寫回避免不一致)
$mirrorColumns = [
'tax_invoice_enabled', 'card_terminal_enabled', 'scan_pay_esun_enabled',
'scan_pay_linepay_enabled', 'shopping_cart_enabled', 'welcome_gift_enabled',
'cash_module_enabled', 'member_system_enabled', 'ambient_temp_monitoring_enabled',
];
// 4. 合併 JSON 設定(只覆寫有送來的鍵,其餘保留)
$jsonSettings = $machine->settings ?? [];
foreach ($jsonBoolKeys as $k) {
if (array_key_exists($k, $settings)) {
$jsonSettings[$k] = (bool) $settings[$k];
}
}
// 5. 整理實體欄位更新(貨道 + 鏡像欄位)
// 4. 整理實體欄位更新(僅貨道類型,只覆寫有送來的鍵)
$columnData = [];
foreach ($columnBoolKeys as $k) {
if (array_key_exists($k, $settings)) {
$columnData[$k] = (bool) $settings[$k];
}
}
foreach ($mirrorColumns as $k) {
if (array_key_exists($k, $jsonSettings)) {
$columnData[$k] = (bool) $jsonSettings[$k];
}
if (empty($columnData)) {
return response()->json([
'success' => false,
'code' => 422,
'message' => __('Invalid settings payload')
], 422);
}
$machine->update(array_merge($columnData, [
'settings' => $jsonSettings,
'updater_id' => $user->id,
]));

View File

@ -134,7 +134,7 @@ class ProcessCommandAck implements ShouldQueue
]);
// 記錄維護類指令到機台日誌 (MachineLog)
if (in_array($command->command_type, ['reboot', 'reboot_card', 'lock', 'unlock', 'checkout', 'change', 'reload_stock', 'update_products', 'update_ads'], true)) {
if (in_array($command->command_type, ['reboot', 'reboot_card', 'lock', 'unlock', 'checkout', 'change', 'reload_stock', 'update_products', 'update_ads', 'update_settings'], true)) {
$msgKey = $status === 'success' ? 'log.command.success' : 'log.command.failed';
$level = $status === 'success' ? 'info' : 'warning';

View File

@ -36,6 +36,7 @@ class Product extends Model
'name_dictionary_key',
'barcode',
'spec',
'spec_dictionary_key',
'manufacturer',
'description',
'price',
@ -68,7 +69,7 @@ class Product extends Model
/**
* 自動附加到 JSON/陣列輸出的屬性(供 Alpine.js 等前端使用)
*/
protected $appends = ['localized_name'];
protected $appends = ['localized_name', 'localized_spec'];
/**
* 取得當前語系的商品名稱。
@ -92,6 +93,26 @@ class Product extends Model
return $this->name ?? '';
}
/**
* 取得當前語系的商品規格。
* 回退順序:當前語系 zh_TW spec 欄位(與名稱同邏輯)
*/
public function getLocalizedSpecAttribute(): string
{
if ($this->relationLoaded('specTranslations') && $this->specTranslations->isNotEmpty()) {
$locale = app()->getLocale();
$translation = $this->specTranslations->firstWhere('locale', $locale);
if ($translation) {
return $translation->value;
}
$fallback = $this->specTranslations->firstWhere('locale', 'zh_TW');
if ($fallback) {
return $fallback->value;
}
}
return $this->spec ?? '';
}
/**
* Get the translations for the product name.
*/
@ -101,6 +122,15 @@ class Product extends Model
->where('group', 'product');
}
/**
* Get the translations for the product spec.
*/
public function specTranslations()
{
return $this->hasMany(\App\Models\System\Translation::class, 'key', 'spec_dictionary_key')
->where('group', 'product_spec');
}
/**
* 倉庫庫存紀錄
*/

View File

@ -111,5 +111,73 @@ class Company extends Model
return $enableCustomBranding && !empty($logoPath);
}
/**
* 取得指定公司「所有機台已開語系的聯集」(商品翻譯實際要維護的語系)
*
* - 來源:各機台 settings['languages'] 去重後聯集。
* - 一律保證含 fallback 語系 (zh_TW),且依系統白名單定義順序排列(穩定、不隨機台增減跳動)。
* - 僅回傳仍在白名單內的語系(機台殘留的失效語系自動濾除)。
* - 公司沒有任何機台、或機台皆未設語系時:退化為僅 [fallback]
*
* 結果快取於 'company_active_locales:{companyId}',於機台語系異動時失效
* ( forgetActiveLocales())
*
* @param int|null $companyId null 代表系統層級(無租戶機台),直接回 [fallback]
* @return array<int,string>
*/
public static function activeLocalesFor(?int $companyId): array
{
$fallback = config('locales.fallback', 'zh_TW');
$whitelist = array_keys(config('locales.supported', [$fallback => $fallback]));
if ($companyId === null) {
return [$fallback];
}
return \Illuminate\Support\Facades\Cache::rememberForever(
"company_active_locales:{$companyId}",
function () use ($companyId, $fallback, $whitelist) {
// 繞過全域範圍:本計算需涵蓋公司全部機台,與登入者可見範圍無關。
$langSets = Machine::withoutGlobalScopes()
->where('company_id', $companyId)
->pluck('settings');
$union = [];
foreach ($langSets as $settings) {
foreach ((array) ($settings['languages'] ?? []) as $locale) {
$union[$locale] = true;
}
}
// 保證含 fallback並只保留白名單內語系、依白名單順序輸出。
$union[$fallback] = true;
return array_values(array_filter(
$whitelist,
fn ($locale) => isset($union[$locale])
));
}
);
}
/**
* 失效指定公司的語系聯集快取(機台 settings.languages 異動後呼叫)。
*/
public static function forgetActiveLocales(?int $companyId): void
{
if ($companyId !== null) {
\Illuminate\Support\Facades\Cache::forget("company_active_locales:{$companyId}");
}
}
/**
* 實例代理:當前公司的語系聯集。
*
* @return array<int,string>
*/
public function activeLocales(): array
{
return static::activeLocalesFor($this->id);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Services\Product;
use App\Models\Product\Product;
use App\Models\System\Company;
use Illuminate\Support\Facades\Cache;
class ProductCatalogService
@ -44,11 +45,14 @@ class ProductCatalogService
*/
public function rebuildCache(?int $companyId): array
{
// 公司機台語系聯集:商品 i18n map 下發的語系集合(見 product_multilingual_spec §5.1
$locales = Company::activeLocalesFor($companyId);
$products = Product::where('company_id', $companyId)
->with(['translations'])
->with(['translations', 'specTranslations'])
->active()
->get()
->map(fn($p) => $this->mapProduct($p));
->map(fn($p) => $this->mapProduct($p, $locales));
$payload = [
'success' => true,
@ -75,10 +79,15 @@ class ProductCatalogService
/**
* Map a Product model to the catalog format expected by machines.
*
* 相容鐵則既有欄位t060v01 / t060v01_en / t060v01_jp / t060v03 …)一律保留,
* 僅「新增」locale-keyed t060v01_i18n名稱 t060v03_i18n規格
* 線上舊 App 忽略新欄位、照舊運作;新版 App *_i18n 以支援多語系切換。
*
* @param Product $product
* @param array<int,string> $locales 公司機台語系聯集i18n map 下發的語系集合)
* @return array
*/
private function mapProduct($product): array
private function mapProduct($product, array $locales = ['zh_TW']): array
{
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
@ -88,7 +97,9 @@ class ProductCatalogService
't060v01' => $product->name,
't060v01_en' => $nameEn,
't060v01_jp' => $nameJp,
't060v01_i18n' => $this->buildI18nMap($product->translations, $product->name, $locales),
't060v03' => $product->spec ?? '',
't060v03_i18n' => $this->buildI18nMap($product->specTranslations, $product->spec ?? '', $locales),
't060v06' => $product->image_url
? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url))
: '',
@ -102,4 +113,29 @@ class ProductCatalogService
't063v03' => (float) $product->price,
];
}
/**
* 依公司機台語系聯集,建立 locale 文字 i18n map。
*
* - 僅輸出 $locales 內的語系。
* - 缺翻譯時回退至 zh_TW再回退至 $fallbackTextproducts.name / spec 主值),
* 確保不下發空字串,避免機台顯示空白。
*
* @param \Illuminate\Support\Collection $translations 該欄位的翻譯集合
* @param string|null $fallbackText 最終回退文字(主欄位值)
* @param array<int,string> $locales 公司機台語系聯集
* @return array<string,string>
*/
private function buildI18nMap($translations, ?string $fallbackText, array $locales): array
{
$zhFallback = $translations->firstWhere('locale', 'zh_TW')?->value
?? ($fallbackText ?? '');
$map = [];
foreach ($locales as $locale) {
$map[$locale] = $translations->firstWhere('locale', $locale)?->value ?: $zhFallback;
}
return $map;
}
}

View File

@ -189,6 +189,10 @@ return [
'type' => 'string',
'description' => '購物方式(新定義)← machines.settings.shopping_modebasic / employee_card / pickup_sheet',
],
'data.LangSet' => [
'type' => 'object',
'description' => "機台顯示語系(新定義,最多 5 種)← machines.settings.languages\n• Languages ← 有序語系陣列(如 [\"zh_TW\",\"en\",\"ja\"],順序即切換順序)\n• Default ← 預設語系(清單第一個,開機/idle 顯示)\n未設定時退化為 [fallback]。機台據此渲染語系切換 UI可選語系池子見 config/locales.php。商品名稱/規格的對應翻譯由 B012 的 t060v01_i18n / t060v03_i18n 提供。",
],
'data.OperationSet' => [
'type' => 'object',
'description' => "運作參數(新定義;來源 machines 實體欄位):\n• CardReaderSeconds ← card_reader_seconds整數刷卡機秒數\n• PaymentBufferSeconds ← payment_buffer_seconds整數金流緩衝秒數\n• CheckoutTime1 ← card_reader_checkout_time_1時間字串\n• CheckoutTime2 ← card_reader_checkout_time_2\n• HeatingStartTime ← heating_start_time加熱開始\n• HeatingEndTime ← heating_end_time加熱結束",
@ -256,6 +260,10 @@ return [
'AmbientTemp' => false,
],
'ShoppingMode' => 'basic',
'LangSet' => [
'Languages' => ['zh_TW', 'en', 'ja'],
'Default' => 'zh_TW',
],
'OperationSet' => [
'CardReaderSeconds' => 30,
'PaymentBufferSeconds' => 5,
@ -282,7 +290,7 @@ return [
'slug' => 'b016-settings-writeback',
'method' => 'PATCH',
'path' => '/api/v1/app/machine/setting/B016',
'description' => '機台主控台「系統設定」頁由系統方編輯後回寫雲端,為 B014 下載的反向操作。需帶 B000 核發之 User Token且僅系統管理員可操作。鍵名採後端原始 snake_case非 B014 的 PascalCase *Set僅白名單布林開關生效不接受 shopping_mode 等非開關欄位。',
'description' => '機台主控台由系統方設定硬體貨道類型後回寫雲端,為 B014 下載的反向操作。需帶 B000 核發之 User Token且僅系統管理員可操作。機台端現在只回寫硬體貨道類型 (is_spring_slot_*)其餘系統設定純由後台單向決定B016 不再接收。',
'headers' => [
'Authorization' => 'Bearer <user_token>',
'Content-Type' => 'application/json',
@ -297,8 +305,8 @@ return [
'settings' => [
'type' => 'object',
'required' => true,
'description' => "開關鍵值物件(皆為布林),鍵名採後端原始 snake_case只覆寫有送來的鍵、其餘保留。白名單外鍵一律忽略。可回寫鍵\n• 支付旗標shopping_cart_enabled / tax_invoice_enabled / card_terminal_enabled / credit_card_enabled / mobile_pay_enabled / card_pay_enabled / scan_pay_enabled / scan_pay_esun_enabled / scan_pay_tappay_enabled / cash_module_enabled / scan_pay_linepay_enabled / tappay_linepay / tappay_jkopay / tappay_easywallet / tappay_pipay / tappay_pluspay\n• 現金面額cash_bill_1000 / cash_bill_500 / cash_bill_100 / cash_coin_50 / cash_coin_10 / cash_coin_5 / cash_coin_1\n• 功能模組pickup_module_enabled / pickup_code_enabled / pass_code_enabled / welcome_gift_enabled / member_system_enabled / ambient_temp_monitoring_enabled\n• 貨道類型machines 實體欄位is_spring_slot_1_10 / 11_20 / 21_30 / 31_40 / 41_50 / 51_60true=彈簧 / false=履帶)",
'example' => ['tax_invoice_enabled' => true, 'is_spring_slot_1_10' => false]
'description' => "貨道類型鍵值物件(皆為布林),鍵名採後端原始 snake_case只覆寫有送來的鍵、其餘保留。白名單外鍵一律忽略。可回寫鍵\n• 貨道類型machines 實體欄位is_spring_slot_1_10 / 11_20 / 21_30 / 31_40 / 41_50 / 51_60true=彈簧 / false=履帶)",
'example' => ['is_spring_slot_1_10' => false]
],
],
'response_parameters' => [
@ -318,9 +326,8 @@ return [
'request' => [
'machine' => 'SN202604130001',
'settings' => [
'tax_invoice_enabled' => true,
'cash_module_enabled' => false,
'is_spring_slot_1_10' => true,
'is_spring_slot_11_20' => false,
],
],
'response' => [
@ -328,7 +335,7 @@ return [
'code' => 200,
'message' => 'Settings updated successfully.',
],
'notes' => '僅系統管理員 (identity=system) 可操作,非系統方回 403 Forbidden查無序號回 404 Machine not foundsettings 非物件回 422 Invalid settings payload。寫回時同步更新 machines.settings JSON 與 is_spring_slot_* 實體欄位並記錄 updater_id套用 withoutGlobalScopes 跨租戶定位機台。'
'notes' => '僅系統管理員 (identity=system) 可操作,非系統方回 403 Forbidden查無序號回 404 Machine not foundsettings 非物件或未含任何有效貨道鍵回 422 Invalid settings payload。寫回時更新 is_spring_slot_* 實體欄位並記錄 updater_id套用 withoutGlobalScopes 跨租戶定位機台。'
],
[
'name' => 'B005: 廣告清單同步 (Ad Sync)',
@ -460,14 +467,16 @@ return [
],
'data' => [
'type' => 'array',
'description' => '商品明細物件陣列。欄位包含t060v00(ID), t060v01(名稱), t060v03(規格), t060v06(圖片), t060v09(售價), t060v11(預設上限), spring_limit(彈簧上限), track_limit(履帶上限)',
'description' => '商品明細物件陣列。欄位包含t060v00(ID), t060v01(名稱), t060v01_en/jp(既有英日名稱,保留相容), t060v01_i18n(名稱多語系 map), t060v03(規格), t060v03_i18n(規格多語系 map), t060v06(圖片), t060v09(售價), t060v11(預設上限), spring_limit(彈簧上限), track_limit(履帶上限)。多語系 map 的 key 集合=公司機台語系聯集,缺翻譯回退 zh_TW既有欄位一律保留以相容線上舊 App。',
'example' => [
[
't060v00' => '1',
't060v01' => '可口可樂 330ml',
't060v01_en' => 'Coca Cola',
't060v01_jp' => 'コカコーラ',
't060v01_i18n' => ['zh_TW' => '可口可樂 330ml', 'en' => 'Coca Cola', 'ja' => 'コカコーラ'],
't060v03' => 'Cold Drink',
't060v03_i18n' => ['zh_TW' => 'Cold Drink', 'en' => 'Cold Drink', 'ja' => 'コールドドリンク'],
't060v06' => 'https://.../coke.png',
't060v09' => 25.0,
't060v11' => 10,
@ -491,7 +500,9 @@ return [
't060v01' => '可口可樂 330ml',
't060v01_en' => 'Coca Cola 330ml',
't060v01_jp' => 'コカコーラ 330ml',
't060v01_i18n' => ['zh_TW' => '可口可樂 330ml', 'en' => 'Coca Cola 330ml', 'ja' => 'コカコーラ 330ml'],
't060v03' => '經典原味,冰涼好滋味',
't060v03_i18n' => ['zh_TW' => '經典原味,冰涼好滋味', 'en' => 'Classic taste, ice cold', 'ja' => 'クラシックな味わい'],
't060v06' => 'https://cloud.star.com/storage/products/coke.png',
't060v09' => 25.0,
't060v11' => 10,

48
config/locales.php Normal file
View File

@ -0,0 +1,48 @@
<?php
/*
|--------------------------------------------------------------------------
| 系統支援語系白名單 (Master Locale Whitelist)
|--------------------------------------------------------------------------
|
| 系統目前支援的所有語系,作為以下用途的唯一來源:
| 1. 機台設定頁「顯示語系」可勾選清單(管理員從此白名單替機台挑語系,最多 5 種)。
| 2. 後台儲存語系時updateSystemSettingslanguages 欄位的驗證白名單。
|
| 注意:商品編輯頁的語系 Tab 不是直接讀白名單,而是讀「公司所有機台已開語系
| 的聯集」(Company::activeLocales())。白名單是「機台能挑的池子」,聯集是
| 「商品實際要維護的語系」。
|
| 詳見 docs/API/product_multilingual_spec.md。
|
*/
return [
// 每台機台最多可勾選的語系數量
'max_per_machine' => 5,
// 缺翻譯時的回退語系(亦為商品名稱/規格的最終預設值)
'fallback' => 'zh_TW',
/*
| 支援語系locale => 顯示名稱。
| 標籤採「原文(中文註記)」格式:因後台為我方使用,原文(如 ภาษาไทย、Tiếng Việt
| 不易辨識,故一律附上中文註記,方便管理員一眼判斷是哪種語言。
| 陣列順序即為各處 UI 的呈現順序,請維持 zh_TW 在最前。
*/
'supported' => [
'zh_TW' => '繁體中文',
'en' => 'English英文',
'ja' => '日本語(日文)',
'ko' => '한국어(韓文)',
'th' => 'ภาษาไทย(泰文)',
'zh_CN' => '简体中文(簡中)',
'vi' => 'Tiếng Việt越南文',
'fr' => 'Français法文',
'de' => 'Deutsch德文',
'es' => 'Español西班牙文',
'id' => 'Bahasa Indonesia印尼文',
],
];

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 商品「規格 (spec)」多語系:比照名稱的 name_dictionary_key
* 為規格新增獨立字典鍵,對應 translations.group = 'product_spec'
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->uuid('spec_dictionary_key')->nullable()->after('spec')->comment('規格多語系 Key');
$table->index('spec_dictionary_key');
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropIndex(['spec_dictionary_key']);
$table->dropColumn('spec_dictionary_key');
});
}
};

View File

@ -493,6 +493,7 @@
"Date Range": "Date Range",
"Day Before": "Day Before",
"Days": "Days",
"Default": "Default",
"Default BG": "Default BG",
"Default Card BG": "Default Card BG",
"Default Donate": "Default Donate",
@ -576,6 +577,7 @@
"Dispense successful": "Dispense successful",
"Dispensing": "Dispensing",
"Dispensing in progress": "Dispensing in progress",
"Display Languages": "Display Languages",
"Display Material Code": "Display Material Code",
"Displayed below \"Welcome Back\" on the login portal.": "Displayed below \"Welcome Back\" on the login portal.",
"Displayed below 'Welcome Back' on the login portal.": "Displayed below 'Welcome Back' on the login portal.",
@ -955,6 +957,7 @@
"Key": "Key",
"Key No": "Key No",
"LEVEL TYPE": "LEVEL TYPE",
"LINE Official Pay": "LINE Official Pay",
"LINE Pay": "LINE Pay",
"LINE Pay Direct": "LINE Pay Direct",
"LINE Pay Direct Settings Description": "LINE Pay Direct Settings Description",
@ -1136,6 +1139,7 @@
"Management of operational parameters and models": "Management of operational parameters and models",
"Manual Sync Ads": "Manual Sync Ads",
"Manual Sync Products": "Manual Sync Products",
"Manual Sync Settings": "Manual Sync Settings",
"Manufacturer": "Manufacturer",
"Marketing and Loyalty": "Marketing and Loyalty",
"Material Code": "Material Code",
@ -1843,6 +1847,7 @@
"Select date to sync data": "Select date to sync data",
"Select flavor...": "Select flavor...",
"Select future time...": "Select update time...",
"Select up to :max languages the machine can switch between. The first one is the default.": "Select up to :max languages the machine can switch between. The first one is the default.",
"Select...": "Select...",
"Selected": "Selected",
"Selected Date": "Selected Date",
@ -1911,6 +1916,7 @@
"Source of temperature limit settings": "Source of temperature limit settings",
"Special Permission": "Special Permission",
"Specification": "Specification",
"Specification (Multilingual)": "Specification (Multilingual)",
"Specifications": "Specifications",
"Spring": "Spring",
"Spring Channel Limit": "Spring Channel Limit",
@ -1999,6 +2005,7 @@
"Survey Analysis": "Survey Analysis",
"Sync Ads": "Sync Ads",
"Sync Products": "Sync Products",
"Sync Settings": "Sync Settings",
"Sync command sent successfully.": "Sync command sent successfully.",
"Sync to All Machines": "Sync to All Machines",
"Sync to Machine": "Sync to Machine",
@ -2062,6 +2069,7 @@
"The Super Admin role name cannot be modified.": "The Super Admin role name cannot be modified.",
"The image is too large. Please upload an image smaller than 1MB.": "The image is too large. Please upload an image smaller than 1MB.",
"The image is too large. Please upload an image smaller than 5MB.": "The image is too large. Please upload an image smaller than 5MB.",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "The machine will be notified to fetch the latest system settings. It may take a moment to apply.",
"This Month": "This Month",
"This Week": "This Week",
"This account does not belong to this company.": "This account does not belong to this company.",
@ -2305,6 +2313,7 @@
"Yes, regenerate": "Yes, regenerate",
"Yesterday": "Yesterday",
"You can assign or change the personnel handling this order": "You can assign or change the personnel handling this order",
"You can select at most :max languages.": "You can select at most :max languages.",
"You cannot assign permissions you do not possess.": "You cannot assign permissions you do not possess.",
"You cannot delete your own account.": "You cannot delete your own account.",
"You do not have permission to access replenishment orders": "You do not have permission to access replenishment orders",
@ -2341,6 +2350,7 @@
"command.unlock": "Unlock",
"command.update_ads": "Sync Ads",
"command.update_products": "Sync Products",
"command.update_settings": "Sync Settings",
"companies": "Customer Management",
"company_id": "company_id",
"completed": "Pickup Completed",
@ -2514,6 +2524,5 @@
"video": "Video",
"visit_gift": "Visit Gift",
"vs Yesterday": "vs Yesterday",
"warning": "warning",
"LINE Official Pay": "LINE Official Pay"
"warning": "warning"
}

View File

@ -493,6 +493,7 @@
"Date Range": "日付範囲",
"Day Before": "一昨日",
"Days": "日",
"Default": "デフォルト",
"Default BG": "デフォルト背景",
"Default Card BG": "デフォルトカード背景",
"Default Donate": "デフォルトで寄付する",
@ -576,6 +577,7 @@
"Dispense successful": "搬送成功",
"Dispensing": "出庫",
"Dispensing in progress": "搬送中",
"Display Languages": "表示言語",
"Display Material Code": "品目コードを表示",
"Displayed below \"Welcome Back\" on the login portal.": "ログイン画面の「おかえりなさい」の下に表示されます。",
"Displayed below 'Welcome Back' on the login portal.": "ログイン画面の「おかえりなさい」の下に表示されます。",
@ -955,6 +957,7 @@
"Key": "キー",
"Key No": "鍵番号",
"LEVEL TYPE": "レベルタイプ",
"LINE Official Pay": "LINE 公式決済",
"LINE Pay": "LINE Pay",
"LINE Pay Direct": "LINE Pay 直結",
"LINE Pay Direct Settings Description": "LINE Pay 直結設定",
@ -1136,6 +1139,7 @@
"Management of operational parameters and models": "運用パラメータとモデルの管理",
"Manual Sync Ads": "手動広告同期",
"Manual Sync Products": "手動商品同期",
"Manual Sync Settings": "手動設定同期",
"Manufacturer": "製造メーカー",
"Marketing and Loyalty": "Marketing and Loyalty",
"Material Code": "資材コード",
@ -1843,6 +1847,7 @@
"Select date to sync data": "データを同期する日付を選択",
"Select flavor...": "Select flavor...",
"Select future time...": "配信日時を選択してください...",
"Select up to :max languages the machine can switch between. The first one is the default.": "機台が切り替え可能な言語を選択してください(最大 :max 種)。最初の言語がデフォルトになります。",
"Select...": "選択してください...",
"Selected": "選択中",
"Selected Date": "選択日",
@ -1911,6 +1916,7 @@
"Source of temperature limit settings": "温度制限設定のソース",
"Special Permission": "特別権限",
"Specification": "仕様",
"Specification (Multilingual)": "仕様(多言語)",
"Specifications": "仕様",
"Spring": "スプリング",
"Spring Channel Limit": "スプリングスロット上限",
@ -1999,6 +2005,7 @@
"Survey Analysis": "アンケート分析",
"Sync Ads": "広告を同期",
"Sync Products": "商品データを同期",
"Sync Settings": "設定を同期",
"Sync command sent successfully.": "同期コマンドが正常に送信されました。",
"Sync to All Machines": "全機器へ同期",
"Sync to Machine": "機器へ広告を同期",
@ -2062,6 +2069,7 @@
"The Super Admin role name cannot be modified.": "特権管理者ロールの名前は変更できません。",
"The image is too large. Please upload an image smaller than 1MB.": "画像サイズが大きすぎます。1MB未満の画像をアップロードしてください。",
"The image is too large. Please upload an image smaller than 5MB.": "画像サイズが大きすぎます。5MB未満の画像をアップロードしてください。",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "最新のシステム設定を取得するよう機台に通知しました。反映まで少々時間がかかる場合があります。",
"This Month": "今月",
"This Week": "今週",
"This account does not belong to this company.": "このアカウントは該当企業に所属していません。",
@ -2305,6 +2313,7 @@
"Yes, regenerate": "はい、再生成します",
"Yesterday": "昨日",
"You can assign or change the personnel handling this order": "この注文を処理する担当者を割り当て、または変更できます",
"You can select at most :max languages.": "最大 :max 言語まで選択できます。",
"You cannot assign permissions you do not possess.": "自身が持っていない権限を割り当てることはできません。",
"You cannot delete your own account.": "自分自身のアカウントは削除できません。",
"You do not have permission to access replenishment orders": "補充伝票のアクセス権限がありません",
@ -2341,6 +2350,7 @@
"command.unlock": "ロック解除",
"command.update_ads": "広告同期",
"command.update_products": "商品同期",
"command.update_settings": "システム設定同期",
"companies": "顧客管理",
"company_id": "company_id",
"completed": "受取完了",
@ -2514,6 +2524,5 @@
"video": "動画",
"visit_gift": "来店ギフト",
"vs Yesterday": "昨日比",
"warning": "警告",
"LINE Official Pay": "LINE 公式決済"
"warning": "警告"
}

View File

@ -493,6 +493,7 @@
"Date Range": "日期區間",
"Day Before": "前日",
"Days": "天",
"Default": "預設",
"Default BG": "預設大背景",
"Default Card BG": "預設卡片背景",
"Default Donate": "預設捐贈",
@ -576,6 +577,7 @@
"Dispense successful": "出貨成功",
"Dispensing": "出貨",
"Dispensing in progress": "正在出貨中",
"Display Languages": "顯示語系",
"Display Material Code": "顯示物料代碼",
"Displayed below \"Welcome Back\" on the login portal.": "顯示於登入入口「歡迎回來」下方。",
"Displayed below 'Welcome Back' on the login portal.": "顯示於登入入口「歡迎回來」下方。",
@ -955,6 +957,7 @@
"Key": "金鑰 (Key)",
"Key No": "鑰匙編號",
"LEVEL TYPE": "層級類型",
"LINE Official Pay": "Line官方支付",
"LINE Pay": "LINE Pay",
"LINE Pay Direct": "LINE Pay 官方直連",
"LINE Pay Direct Settings Description": "LINE Pay 官方直連設定",
@ -1136,6 +1139,7 @@
"Management of operational parameters and models": "管理運作參數與型號",
"Manual Sync Ads": "手動同步機台廣告",
"Manual Sync Products": "手動同步商品",
"Manual Sync Settings": "手動同步系統設定",
"Manufacturer": "製造商",
"Marketing and Loyalty": "行銷與點數",
"Material Code": "物料代碼",
@ -1843,6 +1847,7 @@
"Select date to sync data": "選擇日期以同步數據",
"Select flavor...": "選擇風味...",
"Select future time...": "選擇更新時間",
"Select up to :max languages the machine can switch between. The first one is the default.": "選擇機台可切換的語系(最多 :max 種),第一個為預設語系。",
"Select...": "請選擇...",
"Selected": "已選擇",
"Selected Date": "查詢日期",
@ -1911,6 +1916,7 @@
"Source of temperature limit settings": "溫度限制設定來源",
"Special Permission": "特殊權限",
"Specification": "規格",
"Specification (Multilingual)": "規格(多語系)",
"Specifications": "規格",
"Spring": "彈簧",
"Spring Channel Limit": "彈簧貨道上限",
@ -1999,6 +2005,7 @@
"Survey Analysis": "問卷分析",
"Sync Ads": "同步廣告",
"Sync Products": "同步商品資料",
"Sync Settings": "同步設定",
"Sync command sent successfully.": "同步指令已成功送出",
"Sync to All Machines": "同步到所有機台",
"Sync to Machine": "同步廣告至機台",
@ -2062,6 +2069,7 @@
"The Super Admin role name cannot be modified.": "超級管理員角色的名稱無法修改。",
"The image is too large. Please upload an image smaller than 1MB.": "圖片檔案太大,請上傳小於 1MB 的圖片。",
"The image is too large. Please upload an image smaller than 5MB.": "圖片檔案太大,請上傳小於 5MB 的圖片。",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "已通知機台回抓最新系統設定,套用可能需要一點時間。",
"This Month": "本月",
"This Week": "本週",
"This account does not belong to this company.": "此帳號不屬於該公司。",
@ -2305,6 +2313,7 @@
"Yes, regenerate": "是的,重新產生",
"Yesterday": "昨日",
"You can assign or change the personnel handling this order": "您可以指派或變更處理此訂單的人員",
"You can select at most :max languages.": "最多只能選擇 :max 種語系。",
"You cannot assign permissions you do not possess.": "您無法指派您自身不具備的權限。",
"You cannot delete your own account.": "您無法刪除自己的帳號。",
"You do not have permission to access replenishment orders": "您沒有機台補貨單的權限",
@ -2341,6 +2350,7 @@
"command.unlock": "解鎖",
"command.update_ads": "同步廣告",
"command.update_products": "同步商品",
"command.update_settings": "同步系統設定",
"companies": "客戶管理",
"company_id": "公司",
"completed": "取貨完成",
@ -2514,6 +2524,5 @@
"video": "影片",
"visit_gift": "來店禮",
"vs Yesterday": "較昨日",
"warning": "警告",
"LINE Official Pay": "Line官方支付"
"warning": "警告"
}

View File

@ -37,6 +37,70 @@
this.isUpdatingSetting = false;
}
},
// ── 同步系統設定 (通知機台 APP 回抓 B014) ──
showSyncSettingsModal: false,
isSyncingSettings: false,
syncSettingsMachineId: null,
syncSettingsMachineLocked: false,
syncSettingsNote: '',
syncMachineSearch: '',
syncMachineDropdownOpen: false,
allMachinesList: @js($allMachines ?? []),
openSyncSettingsModal(machineId = null) {
this.syncSettingsMachineId = machineId ? Number(machineId) : null;
this.syncSettingsMachineLocked = machineId !== null;
this.syncSettingsNote = '';
this.syncMachineSearch = '';
this.syncMachineDropdownOpen = false;
this.showSyncSettingsModal = true;
},
get filteredSyncMachines() {
const kw = this.syncMachineSearch.trim().toLowerCase();
if (!kw) return this.allMachinesList;
return this.allMachinesList.filter(m =>
(m.name || '').toLowerCase().includes(kw) ||
(m.serial_no || '').toLowerCase().includes(kw)
);
},
selectSyncMachine(id) {
this.syncSettingsMachineId = Number(id);
this.syncMachineDropdownOpen = false;
this.syncMachineSearch = '';
},
syncSelectedMachineLabel() {
const m = this.allMachinesList.find(x => x.id == this.syncSettingsMachineId);
return m ? `${m.name} (${m.serial_no})` : '';
},
async executeSyncSettings() {
if (this.isSyncingSettings) return;
if (!this.syncSettingsMachineId) {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Please select a machine first') }}', type: 'error' } }));
return;
}
this.isSyncingSettings = true;
try {
const res = await fetch(`/admin/basic-settings/machines/${this.syncSettingsMachineId}/sync-settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=\'csrf-token\']').content,
'Accept': 'application/json'
},
body: JSON.stringify({ note: this.syncSettingsNote })
});
const data = await res.json();
if (data.success) {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message, type: 'success' } }));
this.showSyncSettingsModal = false;
} else {
throw new Error(data.message || 'Sync failed');
}
} catch (e) {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: e.message, type: 'error' } }));
} finally {
this.isSyncingSettings = false;
}
},
async submitMachineSettings() {
if (this.isUpdatingSetting || !this.currentMachine) return;
this.isUpdatingSetting = true;
@ -146,10 +210,27 @@
member_system_enabled: machine.member_system_enabled === true || machine.member_system_enabled === 1 || machine.member_system_enabled === '1',
// 環境監控
ambient_temp_monitoring_enabled: machine.ambient_temp_monitoring_enabled === true || machine.ambient_temp_monitoring_enabled === 1 || machine.ambient_temp_monitoring_enabled === '1'
ambient_temp_monitoring_enabled: machine.ambient_temp_monitoring_enabled === true || machine.ambient_temp_monitoring_enabled === 1 || machine.ambient_temp_monitoring_enabled === '1',
// 顯示語系(有序,第一個為預設)。僅系統管理員可編輯。
languages: Array.isArray(settings.languages) ? [...settings.languages] : []
};
this.showMachineSettingsModal = true;
},
// ── 顯示語系白名單與互動 ──
localeWhitelist: @js(config('locales.supported', [])),
maxLanguages: {{ (int) config('locales.max_per_machine', 5) }},
toggleLanguage(loc) {
const langs = this.machineSettings.languages || [];
const idx = langs.indexOf(loc);
if (idx === -1) {
if (langs.length >= this.maxLanguages) return; // 已達上限
langs.push(loc);
} else {
langs.splice(idx, 1);
}
this.machineSettings.languages = langs;
},
toggleAllCardTerminal() {
const keys = ['credit_card_enabled', 'mobile_pay_enabled', 'card_pay_enabled'];
const allChecked = keys.every(k => this.machineSettings[k]);
@ -852,6 +933,31 @@
</label>
</div>
</div>
@if(auth()->user()->isSystemAdmin())
<!-- 顯示語系(最多 N 種,第一個為預設)。僅系統管理員可設定。 -->
<div class="p-4 rounded-xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Display Languages') }}</h4>
<span class="text-[11px] font-bold text-slate-400"
x-text="`${(machineSettings.languages || []).length}/${maxLanguages}`"></span>
</div>
<p class="text-[11px] font-medium text-slate-400 mb-3">{{ __('Select up to :max languages the machine can switch between. The first one is the default.', ['max' => (int) config('locales.max_per_machine', 5)]) }}</p>
<div class="flex flex-wrap gap-2">
<template x-for="[loc, label] in Object.entries(localeWhitelist)" :key="loc">
<button type="button" @click="toggleLanguage(loc)"
:disabled="!(machineSettings.languages || []).includes(loc) && (machineSettings.languages || []).length >= maxLanguages"
:class="(machineSettings.languages || []).includes(loc)
? 'bg-slate-900 dark:bg-white text-white dark:text-slate-900 border-slate-900 dark:border-white'
: 'bg-white dark:bg-slate-900 text-slate-500 border-slate-200 dark:border-slate-700 hover:text-slate-800 dark:hover:text-slate-200 disabled:opacity-40 disabled:cursor-not-allowed'"
class="inline-flex items-center gap-2 px-3.5 py-2 rounded-xl border text-xs font-black tracking-wide transition-all">
<span x-text="label"></span>
<span x-show="(machineSettings.languages || [])[0] === loc"
class="px-1.5 py-0.5 rounded bg-cyan-500/20 text-cyan-600 dark:text-cyan-300 text-[9px] uppercase tracking-widest">{{ __('Default') }}</span>
</button>
</template>
</div>
</div>
@endif
</div>
<div
@ -964,6 +1070,137 @@
<!-- Modals & Drawers -->
<!-- Sync System Settings Confirmation Modal -->
<template x-teleport="body">
<div x-show="showSyncSettingsModal" class="fixed inset-0 z-[210] overflow-y-auto" x-cloak>
<div class="flex min-h-screen items-center justify-center p-4 text-center sm:p-0">
{{-- Backdrop --}}
<div x-show="showSyncSettingsModal" 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="showSyncSettingsModal = false"></div>
{{-- Modal Content --}}
<div x-show="showSyncSettingsModal" 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-visible 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">
{{-- Header --}}
<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">
{{-- Command Type --}}
<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">{{ __('Sync Settings') }}</span>
</div>
{{-- Target Machine列表進入時鎖定顯示頂部按鈕進入時可搜尋選擇 --}}
<div class="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 block mb-2">{{ __('Target Machine') }}</span>
{{-- 已鎖定 (從列表列開啟) --}}
<template x-if="syncSettingsMachineLocked">
<div class="flex items-center gap-3 px-4 py-3 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
<svg class="w-4 h-4 text-cyan-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span class="text-sm font-black text-slate-800 dark:text-slate-200 truncate" x-text="syncSelectedMachineLabel()"></span>
</div>
</template>
{{-- 可選擇 (從頂部按鈕開啟) --}}
<template x-if="!syncSettingsMachineLocked">
<div class="relative" @click.outside="syncMachineDropdownOpen = false">
<button type="button" @click="syncMachineDropdownOpen = !syncMachineDropdownOpen"
class="w-full flex items-center justify-between gap-3 px-4 py-3 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 hover:border-cyan-500/50 transition-all text-left">
<span class="text-sm font-bold truncate"
:class="syncSettingsMachineId ? 'text-slate-800 dark:text-slate-200' : 'text-slate-400'"
x-text="syncSettingsMachineId ? syncSelectedMachineLabel() : '{{ __('Search Machine...') }}'"></span>
<svg class="w-4 h-4 text-slate-400 shrink-0 transition-transform" :class="{ 'rotate-180': syncMachineDropdownOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div x-show="syncMachineDropdownOpen" x-transition x-cloak
class="absolute z-[20] mt-2 w-full rounded-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-2xl overflow-hidden">
<div class="p-3 border-b border-slate-100 dark:border-slate-800">
<input type="text" x-model="syncMachineSearch" @click.stop
placeholder="{{ __('Search machine name or code...') }}"
class="luxury-input w-full text-sm py-2.5 px-4">
</div>
<div class="max-h-60 overflow-y-auto py-2">
<template x-for="m in filteredSyncMachines" :key="m.id">
<button type="button" @click="selectSyncMachine(m.id)"
class="w-full text-left px-4 py-3 hover:bg-cyan-500/5 transition-colors flex items-center justify-between gap-3"
:class="{ 'bg-cyan-500/10': syncSettingsMachineId == m.id }">
<span class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate" x-text="m.name"></span>
<span class="text-[11px] font-mono font-bold text-slate-400 uppercase tracking-widest shrink-0" x-text="m.serial_no"></span>
</button>
</template>
<div x-show="filteredSyncMachines.length === 0" class="px-4 py-6 text-center text-xs font-bold text-slate-400">
{{ __('No machines found') }}
</div>
</div>
</div>
</div>
</template>
</div>
{{-- Operation Note --}}
<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="syncSettingsNote"
class="luxury-input w-full min-h-[90px] text-sm py-3 px-4 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus:border-cyan-500/50 rounded-2xl"
placeholder="{{ __('Reason for this command...') }}"></textarea>
</div>
</div>
{{-- 提醒:機台收到後將主動回抓最新系統設定 --}}
<div class="flex items-start gap-3 p-4 mb-6 bg-cyan-500/5 border border-cyan-500/15 rounded-2xl">
<svg class="w-5 h-5 text-cyan-500 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 leading-relaxed">
{{ __('The machine will be notified to fetch the latest system settings. It may take a moment to apply.') }}
</p>
</div>
<div class="flex gap-4">
<button @click="showSyncSettingsModal = 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="executeSyncSettings()" :disabled="isSyncingSettings"
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-60 disabled:cursor-not-allowed flex items-center justify-center gap-2">
<svg x-show="isSyncingSettings" class="w-4 h-4 animate-spin" 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 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span x-text="isSyncingSettings ? '{{ __('Sending...') }}' : '{{ __('Execute') }}'"></span>
</button>
</div>
</div>
</div>
</div>
</template>
<!-- 1. Create Machine Modal -->
<template x-teleport="body">
<div x-show="showCreateMachineModal" class="fixed inset-0 z-[100] overflow-y-auto" x-cloak

View File

@ -58,6 +58,15 @@
</svg>
</button>
</div>
{{-- 同步系統設定:明顯的主要操作按鈕,開啟可選機台的確認彈窗 --}}
<button type="button" @click="openSyncSettingsModal()"
class="btn-luxury-primary h-[42px] whitespace-nowrap flex items-center justify-center gap-2 transition-all duration-300 w-full sm:w-auto">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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-bold uppercase tracking-widest">{{ __('Sync Settings') }}</span>
</button>
</div>
@ -195,13 +204,24 @@
</div>
</td>
<td class="px-6 py-6 text-right">
<button type="button" @click="openMachineSettingsModal({{ json_encode($machine) }})"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
title="{{ __('Edit Settings') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
</button>
<div class="flex items-center justify-end gap-2">
{{-- 同步系統設定:開啟確認彈窗 (機台已預選) --}}
<button type="button" @click="openSyncSettingsModal({{ $machine->id }})"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
title="{{ __('Sync Settings') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
{{-- 編輯系統設定 --}}
<button type="button" @click="openMachineSettingsModal({{ json_encode($machine) }})"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
title="{{ __('Edit Settings') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
</button>
</div>
</td>
</tr>
@empty
@ -328,14 +348,25 @@
</div>
</div>
{{-- Action Button --}}
<button type="button" @click="openMachineSettingsModal({{ json_encode($machine) }})"
class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-cyan-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Z" />
</svg>
{{ __('Edit Settings') }}
</button>
{{-- Action Buttons --}}
<div class="flex items-center gap-3">
{{-- 同步系統設定:開啟確認彈窗 (機台已預選) --}}
<button type="button" @click="openSyncSettingsModal({{ $machine->id }})"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-cyan-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" 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 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
{{ __('Sync Settings') }}
</button>
{{-- 編輯系統設定 --}}
<button type="button" @click="openMachineSettingsModal({{ json_encode($machine) }})"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-cyan-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Z" />
</svg>
{{ __('Edit Settings') }}
</button>
</div>
</div>
@empty
<div class="col-span-full">

View File

@ -1,11 +1,13 @@
@extends('layouts.admin')
@section('content')
<div class="space-y-6" x-data="productForm({
categories: @js($categories),
companies: @js($companies),
<div class="space-y-6" x-data="productForm({
categories: @js($categories),
companies: @js($companies),
companySettings: @js($companySettings),
isEditing: false
locales: @js($locales),
localeLabels: @js($localeLabels),
isEditing: false
})">
<!-- Header -->
<div class="flex items-center justify-between gap-6">
@ -115,33 +117,20 @@
<!-- Main Content Area -->
<div class="flex-1 w-full space-y-8">
<!-- Translation -->
<!-- Translation: Product Name (Multilingual) -->
<div class="luxury-card p-8 rounded-[2.5rem] animate-luxury-in relative z-[40]">
<div class="mb-8">
<div class="mb-6">
<h2 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Product Name (Multilingual)') }}</h2>
</div>
<x-product-locale-tabs field="names" active="activeNameLocale" />
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="space-y-3">
<div class="flex items-center gap-2 h-6">
<span class="px-2.5 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-[10px] font-black text-slate-500 uppercase tracking-widest">{{ __('Traditional Chinese') }}</span>
<span class="text-rose-500 font-bold">*</span>
</div>
<input type="text" name="names[zh_TW]" x-model="formData.names.zh_TW" required class="luxury-input !py-3 text-base font-bold shadow-sm">
</div>
<div class="space-y-3">
<div class="flex items-center gap-2 h-6">
<span class="px-2.5 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-[10px] font-black text-slate-500 uppercase tracking-widest">{{ __('English') }}</span>
</div>
<input type="text" name="names[en]" x-model="formData.names.en" class="luxury-input !py-3 text-base font-bold shadow-sm">
</div>
<div class="space-y-3">
<div class="flex items-center gap-2 h-6">
<span class="px-2.5 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-[10px] font-black text-slate-500 uppercase tracking-widest">{{ __('Japanese') }}</span>
</div>
<input type="text" name="names[ja]" x-model="formData.names.ja" class="luxury-input !py-3 text-base font-bold shadow-sm">
</div>
<!-- Translation: Specification (Multilingual) -->
<div class="luxury-card p-8 rounded-[2.5rem] animate-luxury-in relative z-[35]" style="animation-delay: 50ms">
<div class="mb-6">
<h2 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Specification (Multilingual)') }}</h2>
</div>
<x-product-locale-tabs field="specs" active="activeSpecLocale" :required="false" />
</div>
<!-- Basic Specs Section -->
@ -162,10 +151,6 @@
</div>
</div>
<div class="grid grid-cols-1 gap-6">
<div class="space-y-3">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Specification') }}</label>
<input type="text" name="spec" x-model="formData.spec" class="luxury-input shadow-sm">
</div>
<div class="space-y-3">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Category') }}</label>
<x-searchable-select id="product-category" name="category_id" x-model="formData.category_id" :placeholder="__('Uncategorized')">
@ -376,10 +361,15 @@
companySettings: config.companySettings,
isEditing: config.isEditing,
imagePreview: null,
locales: config.locales,
localeLabels: config.localeLabels,
activeNameLocale: (config.locales && config.locales[0]) || 'zh_TW',
activeSpecLocale: (config.locales && config.locales[0]) || 'zh_TW',
formData: {
names: { zh_TW: '', en: '', ja: '' },
// 依公司機台語系聯集初始化各語系空值
names: Object.fromEntries((config.locales || ['zh_TW']).map(l => [l, ''])),
specs: Object.fromEntries((config.locales || ['zh_TW']).map(l => [l, ''])),
barcode: '',
spec: '',
category_id: '',
manufacturer: '',
track_limit: 15,

View File

@ -2,23 +2,35 @@
@section('content')
@php
// 顯示語系 Tab = 公司所有機台已開語系的聯集(見 docs/API/product_multilingual_spec.md §4.2
$localeLabels = config('locales.supported', ['zh_TW' => '繁體中文']);
$locales = \App\Models\System\Company::activeLocalesFor($product->company_id);
$names = [];
foreach(['zh_TW', 'en', 'ja'] as $locale) {
$specs = [];
foreach ($locales as $locale) {
$names[$locale] = $product->translations->where('locale', $locale)->first()?->value ?? '';
$specs[$locale] = $product->specTranslations->where('locale', $locale)->first()?->value ?? '';
}
// If zh_TW translation is empty, fallback to product->name
// zh_TW 翻譯為空時回退至主欄位
if (empty($names['zh_TW'])) {
$names['zh_TW'] = $product->name;
}
if (empty($specs['zh_TW'])) {
$specs['zh_TW'] = $product->spec;
}
@endphp
<div class="space-y-6" x-data="productForm({
categories: @js($categories),
companies: @js($companies),
<div class="space-y-6" x-data="productForm({
categories: @js($categories),
companies: @js($companies),
companySettings: @js($companySettings),
product: @js($product),
names: @js($names),
isEditing: true
specs: @js($specs),
locales: @js($locales),
localeLabels: @js($localeLabels),
isEditing: true
})">
<!-- Header -->
<div class="flex items-center justify-between gap-6">
@ -131,33 +143,20 @@
<!-- Main Content Area -->
<div class="flex-1 w-full space-y-8">
<!-- Translation Section -->
<!-- Translation Section: Product Name (Multilingual) -->
<div class="luxury-card p-8 rounded-[2.5rem] animate-luxury-in relative z-[40]">
<div class="mb-8">
<div class="mb-6">
<h2 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Product Name (Multilingual)') }}</h2>
</div>
<x-product-locale-tabs field="names" active="activeNameLocale" />
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="space-y-3">
<div class="flex items-center gap-2 h-6">
<span class="px-2.5 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-[10px] font-black text-slate-500 uppercase tracking-widest">{{ __('Traditional Chinese') }}</span>
<span class="text-rose-500 font-bold">*</span>
</div>
<input type="text" name="names[zh_TW]" x-model="formData.names.zh_TW" required class="luxury-input !py-3 text-base font-bold shadow-sm">
</div>
<div class="space-y-3">
<div class="flex items-center gap-2 h-6">
<span class="px-2.5 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-[10px] font-black text-slate-500 uppercase tracking-widest">{{ __('English') }}</span>
</div>
<input type="text" name="names[en]" x-model="formData.names.en" class="luxury-input !py-3 text-base font-bold shadow-sm">
</div>
<div class="space-y-3">
<div class="flex items-center gap-2 h-6">
<span class="px-2.5 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-[10px] font-black text-slate-500 uppercase tracking-widest">{{ __('Japanese') }}</span>
</div>
<input type="text" name="names[ja]" x-model="formData.names.ja" class="luxury-input !py-3 text-base font-bold shadow-sm">
</div>
<!-- Translation Section: Specification (Multilingual) -->
<div class="luxury-card p-8 rounded-[2.5rem] animate-luxury-in relative z-[35]" style="animation-delay: 50ms">
<div class="mb-6">
<h2 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Specification (Multilingual)') }}</h2>
</div>
<x-product-locale-tabs field="specs" active="activeSpecLocale" :required="false" />
</div>
<!-- Basic Specs Section -->
@ -178,10 +177,6 @@
</div>
</div>
<div class="grid grid-cols-1 gap-6">
<div class="space-y-3">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Specification') }}</label>
<input type="text" name="spec" x-model="formData.spec" class="luxury-input shadow-sm">
</div>
<div class="space-y-3">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Category') }}</label>
<x-searchable-select id="product-category" name="category_id" x-model="formData.category_id" :placeholder="__('Uncategorized')">
@ -392,9 +387,14 @@
companySettings: config.companySettings,
isEditing: config.isEditing,
imagePreview: config.product.image_url || null,
locales: config.locales,
localeLabels: config.localeLabels,
activeNameLocale: (config.locales && config.locales[0]) || 'zh_TW',
activeSpecLocale: (config.locales && config.locales[0]) || 'zh_TW',
formData: {
...config.product,
names: config.names,
specs: config.specs,
remove_image: false,
metadata: {
material_code: config.product.metadata?.material_code || '',

View File

@ -48,6 +48,7 @@
'Adjust Stock & Expiry' => __('Adjust Stock & Expiry'),
'Sync Ads' => __('Sync Ads'),
'Sync Products' => __('Sync Products'),
'Sync Settings' => __('Sync Settings'),
'Update App' => __('Update App'),
'Pending' => __('Pending'),
'Sent' => __('Sent'),
@ -456,6 +457,7 @@
'reload_stock': this.translations['Adjust Stock & Expiry'],
'update_ads': this.translations['Sync Ads'],
'update_products': this.translations['Sync Products'],
'update_settings': this.translations['Sync Settings'],
'update_app': this.translations['Update App']
};
return names[type] || type;

View File

@ -97,6 +97,7 @@
'dispense' => __('Remote Dispense'),
'update_ads' => __('Sync Ads'),
'update_products' => __('Sync Products'),
'update_settings' => __('Sync Settings'),
'update_app' => __('Update App'),
]" :selected="request('command_type')" :placeholder="__('All Command Types')"
:hasSearch="false"

View File

@ -0,0 +1,42 @@
@props([
'field', // 'names' | 'specs' — 對應 formData[field] 與送出欄位名稱
'active', // Alpine 狀態變數名稱,如 'activeNameLocale'
'required' => true, // zh_TW 是否必填(名稱必填、規格選填)
])
{{--
商品多語系輸入 Tab名稱 / 規格共用)。
Tab 清單 = 公司機台語系聯集(由父層 productForm 提供 locales / localeLabels
已填寫的語系在 Tab 上顯示小圓點標記完成度。
--}}
<div>
{{-- Tabs --}}
<div class="flex flex-wrap items-center gap-2 mb-6">
<template x-for="loc in locales" :key="'{{ $field }}-tab-' + loc">
<button type="button" @click="{{ $active }} = loc"
:class="{{ $active }} === loc
? 'bg-slate-900 dark:bg-white text-white dark:text-slate-900 border-slate-900 dark:border-white shadow-sm'
: 'bg-white dark:bg-slate-900 text-slate-500 border-slate-200 dark:border-slate-700 hover:text-slate-800 dark:hover:text-slate-200'"
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl border text-xs font-black uppercase tracking-widest transition-all">
<span x-text="localeLabels[loc] || loc"></span>
@if($required)
<span x-show="loc === 'zh_TW'" class="text-rose-400">*</span>
@endif
{{-- 完成度標記:有填內容亮起 --}}
<span class="w-1.5 h-1.5 rounded-full transition-colors"
:class="(formData.{{ $field }}[loc] || '').trim() ? 'bg-cyan-400' : 'bg-slate-300/60 dark:bg-slate-600/60'"></span>
</button>
</template>
</div>
{{-- Inputs所有語系皆在 DOM一併送出僅顯示當前 Tab --}}
<template x-for="loc in locales" :key="'{{ $field }}-input-' + loc">
<div x-show="{{ $active }} === loc" x-cloak>
<input type="text"
:name="'{{ $field }}[' + loc + ']'"
x-model="formData.{{ $field }}[loc]"
:required="{{ $required ? "loc === 'zh_TW'" : 'false' }}"
class="luxury-input !py-3 text-base font-bold shadow-sm w-full">
</div>
</template>
</div>

View File

@ -281,6 +281,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::delete('/{machine}', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'destroy'])->name('destroy');
Route::post('/{machine}/regenerate-token', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'regenerateToken'])->name('regenerate-token');
Route::patch('/{machine}/update-system-settings', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'updateSystemSettings'])->name('update-system-settings');
Route::post('/{machine}/sync-settings', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'syncSettings'])->name('sync-settings');
// 地址轉座標 (Geocoding Proxy)
Route::post('/geocode', [App\Http\Controllers\Admin\GeocodingController::class, 'resolve'])->name('geocode');

View File

@ -0,0 +1,200 @@
<?php
namespace Tests\Feature\Api\V1;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\System\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/**
* B016機台端回寫系統設定。機台僅可編輯硬體貨道 (is_spring_slot_*)
* 其餘系統設定一律忽略;僅系統方 (company_id = null) 可操作。
*/
class MachineSettingB016Test extends TestCase
{
use RefreshDatabase;
private const URL = '/api/v1/app/machine/setting/B016';
private Company $company;
private Machine $machine;
protected function setUp(): void
{
parent::setUp();
$this->company = Company::create(['name' => 'Test Company']);
// 六個貨道初始皆為 false履帶
$this->machine = Machine::factory()->create([
'company_id' => $this->company->id,
'is_spring_slot_1_10' => false,
'is_spring_slot_11_20' => false,
'is_spring_slot_21_30' => false,
'is_spring_slot_31_40' => false,
'is_spring_slot_41_50' => false,
'is_spring_slot_51_60' => false,
]);
}
private function actingAsSystemAdmin(): User
{
$admin = User::factory()->create(['company_id' => null]);
Sanctum::actingAs($admin);
return $admin;
}
/**
* 系統方部分更新:只送兩個貨道 只有這兩個被改,其餘四個維持原值,並寫入 updater_id。
*/
public function test_system_admin_partial_update_only_touches_sent_slots(): void
{
$admin = $this->actingAsSystemAdmin();
$this->patchJson(self::URL, [
'machine' => $this->machine->serial_no,
'settings' => [
'is_spring_slot_1_10' => true,
'is_spring_slot_51_60' => true,
],
])->assertStatus(200)->assertJson(['success' => true, 'code' => 200]);
$this->assertDatabaseHas('machines', [
'id' => $this->machine->id,
'is_spring_slot_1_10' => 1, // 有送 → 改成彈簧
'is_spring_slot_51_60' => 1, // 有送 → 改成彈簧
'is_spring_slot_11_20' => 0, // 未送 → 維持履帶
'is_spring_slot_21_30' => 0,
'is_spring_slot_31_40' => 0,
'is_spring_slot_41_50' => 0,
'updater_id' => $admin->id,
]);
}
/**
* 一次送齊六個貨道:全部正確寫入。
*/
public function test_system_admin_can_update_all_six_slots(): void
{
$this->actingAsSystemAdmin();
$this->patchJson(self::URL, [
'machine' => $this->machine->serial_no,
'settings' => [
'is_spring_slot_1_10' => true,
'is_spring_slot_11_20' => true,
'is_spring_slot_21_30' => true,
'is_spring_slot_31_40' => true,
'is_spring_slot_41_50' => true,
'is_spring_slot_51_60' => true,
],
])->assertStatus(200)->assertJson(['success' => true]);
$this->machine->refresh();
$this->assertTrue($this->machine->is_spring_slot_1_10);
$this->assertTrue($this->machine->is_spring_slot_11_20);
$this->assertTrue($this->machine->is_spring_slot_21_30);
$this->assertTrue($this->machine->is_spring_slot_31_40);
$this->assertTrue($this->machine->is_spring_slot_41_50);
$this->assertTrue($this->machine->is_spring_slot_51_60);
}
/**
* 非貨道鍵(如支付/現金旗標)一律忽略:只跟著的貨道鍵會生效。
*/
public function test_non_slot_keys_are_ignored(): void
{
$this->actingAsSystemAdmin();
$this->patchJson(self::URL, [
'machine' => $this->machine->serial_no,
'settings' => [
'is_spring_slot_1_10' => true,
'cash_module_enabled' => true, // 非白名單 → 忽略
'credit_card_enabled' => true, // 非白名單 → 忽略
'shopping_cart_enabled' => false, // 非白名單 → 忽略
],
])->assertStatus(200)->assertJson(['success' => true]);
$this->machine->refresh();
$this->assertTrue($this->machine->is_spring_slot_1_10);
// 非貨道欄位不存在於 machines 直接欄位,確認沒被當成欄位寫入而報錯即可(請求已 200
}
/**
* 完全沒送任何貨道鍵 422(無可更新欄位)。
*/
public function test_payload_without_any_slot_key_returns_422(): void
{
$this->actingAsSystemAdmin();
$this->patchJson(self::URL, [
'machine' => $this->machine->serial_no,
'settings' => [
'cash_module_enabled' => true,
],
])->assertStatus(422)->assertJson(['success' => false, 'code' => 422]);
}
/**
* settings 非陣列 422
*/
public function test_non_array_settings_returns_422(): void
{
$this->actingAsSystemAdmin();
$this->patchJson(self::URL, [
'machine' => $this->machine->serial_no,
'settings' => 'not-an-array',
])->assertStatus(422)->assertJson(['success' => false, 'code' => 422]);
}
/**
* 機台不存在 404
*/
public function test_unknown_machine_returns_404(): void
{
$this->actingAsSystemAdmin();
$this->patchJson(self::URL, [
'machine' => 'SN-DOES-NOT-EXIST',
'settings' => ['is_spring_slot_1_10' => true],
])->assertStatus(404)->assertJson(['success' => false, 'code' => 404]);
}
/**
* 公司管理員company_id 不為 null,非系統方)→ 403,且資料不變。
*/
public function test_company_admin_is_forbidden(): void
{
$companyAdmin = User::factory()->create([
'company_id' => $this->company->id,
'is_admin' => true,
]);
Sanctum::actingAs($companyAdmin);
$this->patchJson(self::URL, [
'machine' => $this->machine->serial_no,
'settings' => ['is_spring_slot_1_10' => true],
])->assertStatus(403)->assertJson(['success' => false, 'code' => 403]);
$this->assertDatabaseHas('machines', [
'id' => $this->machine->id,
'is_spring_slot_1_10' => 0,
]);
}
/**
* 未帶 token未認證 401
*/
public function test_unauthenticated_returns_401(): void
{
$this->patchJson(self::URL, [
'machine' => $this->machine->serial_no,
'settings' => ['is_spring_slot_1_10' => true],
])->assertStatus(401);
}
}