From 779c59138fc5636da83e4aa6814e9d8184147ae0 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Mon, 15 Jun 2026 09:00:10 +0800 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=E5=95=86=E5=93=81=E8=88=87=E6=A9=9F?= =?UTF-8?q?=E5=8F=B0=E5=A4=9A=E8=AA=9E=E7=B3=BB=E6=9E=B6=E6=A7=8B=EF=BC=8C?= =?UTF-8?q?=E4=B8=A6=E8=A3=9C=E4=B8=8A=E7=B3=BB=E7=B5=B1=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .agents/skills/api-technical-specs/SKILL.md | 31 ++- .../MachineSettingController.php | 112 +++++++- .../Controllers/Admin/ProductController.php | 134 ++++++---- .../Api/V1/App/MachineController.php | 59 ++--- app/Jobs/Machine/ProcessCommandAck.php | 2 +- app/Models/Product/Product.php | 32 ++- app/Models/System/Company.php | 68 +++++ .../Product/ProductCatalogService.php | 42 ++- config/api-docs.php | 25 +- config/locales.php | 48 ++++ ..._spec_dictionary_key_to_products_table.php | 28 ++ lang/en.json | 13 +- lang/ja.json | 13 +- lang/zh_TW.json | 13 +- .../basic-settings/machines/index.blade.php | 239 +++++++++++++++++- .../partials/tab-system-settings.blade.php | 61 +++-- .../views/admin/products/create.blade.php | 54 ++-- resources/views/admin/products/edit.blade.php | 64 ++--- resources/views/admin/remote/index.blade.php | 2 + .../partials/tab-history-index.blade.php | 1 + .../components/product-locale-tabs.blade.php | 42 +++ routes/web.php | 1 + .../Feature/Api/V1/MachineSettingB016Test.php | 200 +++++++++++++++ 23 files changed, 1088 insertions(+), 196 deletions(-) create mode 100644 config/locales.php create mode 100644 database/migrations/2026_06_12_090000_add_spec_dictionary_key_to_products_table.php create mode 100644 resources/views/components/product-locale-tabs.blade.php create mode 100644 tests/Feature/Api/V1/MachineSettingB016Test.php diff --git a/.agents/skills/api-technical-specs/SKILL.md b/.agents/skills/api-technical-specs/SKILL.md index 7dea6c2..ba5be58 100644 --- a/.agents/skills/api-technical-specs/SKILL.md +++ b/.agents/skills/api-technical-specs/SKILL.md @@ -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 `,且**僅系統管理員 (`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` 跨租戶定位機台。 --- diff --git a/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php b/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php index de42f74..b007952 100644 --- a/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php +++ b/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php @@ -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()])); + // 語系異動:失效公司語系聯集快取並重建商品目錄 i18n(B012 下發內容隨之更新) + 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); + } + /** * 刪除機台 (僅限系統管理員) */ diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index c178319..5dab676 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -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) { diff --git a/app/Http/Controllers/Api/V1/App/MachineController.php b/app/Http/Controllers/Api/V1/App/MachineController.php index c4a1e88..02eaebd 100644 --- a/app/Http/Controllers/Api/V1/App/MachineController.php +++ b/app/Http/Controllers/Api/V1/App/MachineController.php @@ -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, ])); diff --git a/app/Jobs/Machine/ProcessCommandAck.php b/app/Jobs/Machine/ProcessCommandAck.php index 96efcbb..b6e074e 100644 --- a/app/Jobs/Machine/ProcessCommandAck.php +++ b/app/Jobs/Machine/ProcessCommandAck.php @@ -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'; diff --git a/app/Models/Product/Product.php b/app/Models/Product/Product.php index e7163d4..dee8914 100644 --- a/app/Models/Product/Product.php +++ b/app/Models/Product/Product.php @@ -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'); + } + /** * 倉庫庫存紀錄 */ diff --git a/app/Models/System/Company.php b/app/Models/System/Company.php index e120595..bd39b6e 100644 --- a/app/Models/System/Company.php +++ b/app/Models/System/Company.php @@ -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 + */ + 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 + */ + public function activeLocales(): array + { + return static::activeLocalesFor($this->id); + } } diff --git a/app/Services/Product/ProductCatalogService.php b/app/Services/Product/ProductCatalogService.php index 5f2ed78..204db9d 100644 --- a/app/Services/Product/ProductCatalogService.php +++ b/app/Services/Product/ProductCatalogService.php @@ -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 $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,再回退至 $fallbackText(products.name / spec 主值), + * 確保不下發空字串,避免機台顯示空白。 + * + * @param \Illuminate\Support\Collection $translations 該欄位的翻譯集合 + * @param string|null $fallbackText 最終回退文字(主欄位值) + * @param array $locales 公司機台語系聯集 + * @return array + */ + 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; + } } diff --git a/config/api-docs.php b/config/api-docs.php index 5d30920..ac280c3 100644 --- a/config/api-docs.php +++ b/config/api-docs.php @@ -189,6 +189,10 @@ return [ 'type' => 'string', 'description' => '購物方式(新定義)← machines.settings.shopping_mode:basic / 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 ', '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_60(true=彈簧 / 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_60(true=彈簧 / 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 found;settings 非物件回 422 Invalid settings payload。寫回時同步更新 machines.settings JSON 與 is_spring_slot_* 實體欄位並記錄 updater_id,套用 withoutGlobalScopes 跨租戶定位機台。' + 'notes' => '僅系統管理員 (identity=system) 可操作,非系統方回 403 Forbidden;查無序號回 404 Machine not found;settings 非物件或未含任何有效貨道鍵回 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, diff --git a/config/locales.php b/config/locales.php new file mode 100644 index 0000000..b0ba466 --- /dev/null +++ b/config/locales.php @@ -0,0 +1,48 @@ + 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(印尼文)', + ], + +]; diff --git a/database/migrations/2026_06_12_090000_add_spec_dictionary_key_to_products_table.php b/database/migrations/2026_06_12_090000_add_spec_dictionary_key_to_products_table.php new file mode 100644 index 0000000..4eca969 --- /dev/null +++ b/database/migrations/2026_06_12_090000_add_spec_dictionary_key_to_products_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/lang/en.json b/lang/en.json index 233b0a2..4a80faf 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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" } diff --git a/lang/ja.json b/lang/ja.json index 538eb6e..9e8dbf3 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -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": "警告" } diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 37a37e9..c635733 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -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": "警告" } diff --git a/resources/views/admin/basic-settings/machines/index.blade.php b/resources/views/admin/basic-settings/machines/index.blade.php index 4232eb8..ac8ff82 100644 --- a/resources/views/admin/basic-settings/machines/index.blade.php +++ b/resources/views/admin/basic-settings/machines/index.blade.php @@ -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 @@ + @if(auth()->user()->isSystemAdmin()) + +
+
+

{{ __('Display Languages') }}

+ +
+

{{ __('Select up to :max languages the machine can switch between. The first one is the default.', ['max' => (int) config('locales.max_per_machine', 5)]) }}

+
+ +
+
+ @endif
+ + +