[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:
parent
a5e5390cd1
commit
779c59138f
@ -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` 跨租戶定位機台。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除機台 (僅限系統管理員)
|
||||
*/
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
]));
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 倉庫庫存紀錄
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,再回退至 $fallbackText(products.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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 <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_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,
|
||||
|
||||
48
config/locales.php
Normal file
48
config/locales.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 系統支援語系白名單 (Master Locale Whitelist)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 系統目前支援的所有語系,作為以下用途的唯一來源:
|
||||
| 1. 機台設定頁「顯示語系」可勾選清單(管理員從此白名單替機台挑語系,最多 5 種)。
|
||||
| 2. 後台儲存語系時(updateSystemSettings)languages 欄位的驗證白名單。
|
||||
|
|
||||
| 注意:商品編輯頁的語系 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(印尼文)',
|
||||
],
|
||||
|
||||
];
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
13
lang/en.json
13
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"
|
||||
}
|
||||
|
||||
13
lang/ja.json
13
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": "警告"
|
||||
}
|
||||
|
||||
@ -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": "警告"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 || '',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
42
resources/views/components/product-locale-tabs.blade.php
Normal file
42
resources/views/components/product-locale-tabs.blade.php
Normal 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>
|
||||
@ -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');
|
||||
|
||||
200
tests/Feature/Api/V1/MachineSettingB016Test.php
Normal file
200
tests/Feature/Api/V1/MachineSettingB016Test.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user