Compare commits

..

12 Commits

Author SHA1 Message Date
32fa28dc0f [DOCS]:初始化 MQTT 架構實作計畫與相關技術規範
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m12s
1. 新增 MQTT 即時通訊與 Topic 規範文件 (.agents/skills/mqtt-communication-specs/SKILL.md)。
2. 建立 MQTT 基礎架構實作計畫文件 (docs/mqtt-implementation-plan.md)。
3. 更新全域開發框架規範 (framework.md),納入 Go Gateway 與 EMQX 架構說明。
4. 重構 IoT 通訊處理規範 (iot-communication/SKILL.md),支援 HTTP 與 MQTT 雙軌管線。
5. 更新背景 API 規範 (api-rules.md) 與技能觸發規則 (skill-trigger.md) 以符合新架構。
2026-04-14 13:02:08 +08:00
daf8b1ebcc [FEAT] 強化機台技術員 Token 安全性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 46s
1. 為 B000 登入發放之技術員 Token 增加 8 小時有效期限,防止 Token 永久有效之風險。
2026-04-13 17:24:57 +08:00
8f008ffb61 [FEAT] 實作 B014 機台參數下載 API 與 B000 登入認證強化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 52s
1. 強化 B000 登入接口:驗證成功後回傳 Sanctum Token 供後續初始化使用。
2. 實作 B014 (getSettings) API:整合機台、金流與發票設定,並映射至 Android App 預期欄位。
3. 強化安全性:B014 API 掛載 auth:sanctum 並執行 RBAC 權限檢查。
4. 更新 API 說明文件 (iot-spec.md, api-docs.php) 及技術規範 (SKILL.md)。
2026-04-13 17:04:52 +08:00
729890d7c7 [DOCS] 更新通訊系統與全域工具列開發藍圖
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 47s
1. 更新 docs/future_todo.md:詳列第一階段核心工具(工具列、公告系統、快捷入口、身分模擬)與第二階段行銷功能(盲盒抽獎)的開發時程。
2026-04-13 16:19:37 +08:00
ad256d3d3b [FEAT] 廣告排程功能與 UI 優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 48s
1. 新增廣告排程功能,支援設定發布時間與下架時間。
2. 整合 Flatpickr 時間選擇器,提供與機台日誌一致的極簡奢華風 UI。
3. 優化廣告列表中的數字字體,套用 font-mono 與 tabular-nums,與客戶管理模組風格同步。
4. 修正 Alpine.js 資料同步邏輯,確保編輯模式下排程時間能正確回填。
2026-04-13 11:44:26 +08:00
5415b14a53 [DOCS] 更新 .gitignore 以排除 pptx 目錄
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m10s
1. 在 .gitignore 中新增 /docs/pptx 路徑,避免產出的簡報檔案意外進入版本控制。
2026-04-13 11:01:21 +08:00
c97776892e [FEAT] 優化客戶合約管理介面與修復日期偏移問題
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 52s
1. 整合「客戶詳情」與「合約歷程」至單一側邊欄,改用分頁 (Tabs) 切換介面。
2. 優化清單「服務期程」顯示:根據客戶模式(租賃/買斷)動態顯示對應期間,並使用完整文字標籤取代縮寫。
3. 修復日期 Bug:在 Company 模型指定日期序列化格式為 Y-m-d,解決時區轉換導致的日期減少一天問題。
4. 新增合約歷程資料表模型、遷移檔以及對應的多語系翻譯(中、英、日)。
5. 移除清單操作列中重複的合約歷程圖示。
2026-04-08 17:41:26 +08:00
a599b14df1 [FEAT] 優化機台硬體通訊協議與管理介面互動性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m6s
1. 修復帳號管理與角色權限頁面搜尋功能,支援 Enter 鍵快捷提交。
2. 完成 B013 (機台故障上報) API 實作,改用非同步隊列 (ProcessMachineError) 處理日誌上報。
3. 精簡 B013 API 參數,移除冗餘的 message 欄位,統一由雲端對照表翻譯。
4. 更新技術規格文件 (SKILL.md) 與系統 API 文件配置 (api-docs.php)。
5. 修正平台管理員帳號在搜尋過濾時的資料隔離邏輯。
2026-04-08 14:52:00 +08:00
c343df34ee [FEAT] 實作 B012 商品同步 API 與統一圖片絕對網址格式
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 58s
1. 實作 B012 API:新增 /api/v1/app/machine/products/B012 端點,支援 GET (全量) 與 PATCH (增量) 同步邏輯。
2. 統一圖片 URL:在 B005 與 B012 API 中使用 asset() 確保回傳絕對網址 (Absolute URL),解決 App 端下載相對路徑的問題。
3. 文件更新:同步更新 SKILL.md 的欄位定義,並在 api-docs.php 補上 B012 的正式規格說明。
4. 資料庫變更:新增 machine_slots 表的 type 欄位與相關註解遷移。
5. 格式優化:為技術規格文件中的 API 欄位與狀態碼加上反引號,提升文件中心可讀性。
2026-04-07 17:05:28 +08:00
253ae8afd4 [FEAT] 實作機台序號編輯功能與多語系支援
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 49s
1. 更新機台控制器 (MachineController),在更新時執行 serial_no 的必填與唯一性驗證。
2. 修改機台管理首頁 (index.blade.php),在快速編輯彈窗中加入機台序號輸入欄位。
3. 修正基礎設定中機台編輯頁面 (edit.blade.php),將原本唯讀的機台序號欄位改為可編輯輸入框,並加入必填標記。
4. 補齊並統一繁體中文、英文、日文翻譯檔中關於「機台序號」的翻譯 Key。
2026-04-07 14:55:24 +08:00
f2147ae6c4 [FEAT] 完善 IoT API 規范化、機台管理介面優化與 B005 改為 GET
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m4s
1. 將 B005 (廣告同步) 從 POST 改為 GET,符合 RESTful 規範。
2. 完善 B009 (庫存回報) 回應規格,加入業務代碼 (200 OK)。
3. API 文件 UI 優化:新增 Method Badge (方法標籤),並修正 JSON 中文/斜線轉義問題。
4. 機台管理介面優化:實作「唯讀庫存與效期」面板,並將日誌圖示改為「👁️」。
5. 標準化 ID 識別邏輯:資料表全面移除對 sku 的依賴,改以 id 為主、barcode 為輔。
6. 新增 Migration:正式移除 sku 欄位並同步 barcode 指向。
7. 更新多語系支援 (zh_TW, en, ja)。
2026-04-07 14:37:57 +08:00
b60afc3abe [FIX] 修正與標準化 B005 廣告下載 API 以相容既有 Android App
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m7s
1. 修改 MachineController@getAdvertisements 回傳欄位,將 t070v02 改為播放秒數,t070v03 改為位置代碼 (Flag)。
2. 根據 Android App 原始碼分析結果,調整位置對應:3 為待機廣告 (HomeActivity),1 為販賣頁廣告 (FontendActivity)。
3. 在 AdvertisementController@getMachineAds 增加 sort_order 排序,確保後台管理介面視圖與 API 輸出順序一致。
4. 更新廣告下載 API 的技術文件 (SKILL.md),明確標記欄位用途與位置代碼。
5. 在 routes/api.php 補上 B005 與 B009 的路由定義。
2026-04-07 11:41:24 +08:00
50 changed files with 3493 additions and 294 deletions

View File

@@ -66,15 +66,22 @@ trigger: always_on
--- ---
## ⚡ 5. 高併發處理與隊列 ## ⚡ 5. IoT 高併發流向與 MQTT Gateway 整合
為了系統穩定性,以下 API **嚴禁直寫資料庫**,必須進入 **Redis Queue** 異步處理 為了系統穩定性與高吞吐量,機台通訊的架構依循以下規範,**嚴禁直寫資料庫**
1. **B010**: 心跳上傳(每 5-10 秒一次)。
2. **B600 / B602**: 交易與出貨紀錄。
3. **B220**: 零錢機庫存變動。
4. **B710**: 計時器狀態同步。
後端應立即回傳 `202 Accepted` 或業務定義的成功碼,由 Job 背景完成數據持久化。 ### 5.1 MQTT 通訊端點 (高頻與事件驅動)
以下高頻或即時事件,未來將**全面改採 MQTT 協議**,透過 EMQX 與 Go Gateway 橋接:
1. **B010 (心跳)**:機台發布至 `machine/{serial_no}/heartbeat`
2. **B013 (錯誤與狀態)**:機台發布至 `machine/{serial_no}/error`
3. **B600 / B602 (交易紀錄)**:機台發布至 `machine/{serial_no}/transaction`
處理管線:
`機台 ➜ EMQX ➜ Go Gateway ➜ Redis List (mqtt_incoming_jobs) ➜ Laravel daemon (mqtt:listen) ➜ Job 異步寫入 DB`
### 5.2 HTTP 通訊端點 (資料拉取與特殊事件)
基於歷史相容、大檔傳輸(如 `B012 商品同步`)或高度安全性(如 `B014 金鑰下載`)的端點,維持使用 HTTP REST API。
若此類 API 產生寫入行為,後端應盡可能立即回傳 `202 Accepted`,並透過 Laravel Job 在背景完成數據持久化。
--- ---

View File

@@ -6,19 +6,28 @@ trigger: always_on
## 1. 專案概述 ## 1. 專案概述
* **目標**打造一個強大且穩定的智能販賣機後台管理系統Cloud 平台),負責管理機台、商品、銷售數據以及提供給端點機台串接的 API。 * **目標**打造一個強大且穩定的智能販賣機後台管理系統Cloud 平台),負責管理機台、商品、銷售數據以及提供給端點機台串接的 API。
* **核心架構**:採用 **傳統單體式架構 (Monolithic Architecture)** Laravel Blade 模板引擎進行伺服器端渲染 (SSR)。 * **核心架構**:採用 **Monorepo 單體式架構**,以 Laravel 為核心進行伺服器端渲染 (SSR) 與 API 服務,並搭配 **Go MQTT Gateway** 作為高併發 IoT 通訊的前置接收層。兩者透過 **Redis** 進行異步橋接,確保職責分離與系統穩定性
* **工作流程**:後端處理業務邏輯與資料庫存取,並透過 Blade 引擎渲染包含 Tailwind CSS 類別的 HTML。前端互動行為由輕量級 Alpine.js 負責UI 元件以 Preline UI 為主體。 * **工作流程**:後端處理業務邏輯與資料庫存取,並透過 Blade 引擎渲染包含 Tailwind CSS 類別的 HTML。前端互動行為由輕量級 Alpine.js 負責UI 元件以 Preline UI 為主體。
## 2. 技術棧 (Tech Stack) ## 2. 技術棧 (Tech Stack)
### 2.1 後端核心 (Laravel)
* **後端框架**PHP 8.5 / Laravel 12 * **後端框架**PHP 8.5 / Laravel 12
* **核心組件**Redis (用於高併發 IoT 隊列與快取,為系統穩定之必要條件) * **核心組件**Redis (用於高併發 IoT 隊列、MQTT 橋接與快取,為系統穩定之必要條件)
* **資料庫**MySQL 8.0
* **開發環境**Laravel Sail (Docker / WSL2)
### 2.2 IoT 通訊層 (MQTT Gateway)
* **MQTT Broker**EMQX 5 (負責維持機台長連線與訊息路由)
* **Gateway 語言**Go (負責訂閱 MQTT Topic、預處理訊息、轉發至 Redis)
* **橋接機制**Redis List (`mqtt_incoming_jobs`),由 Laravel 常駐指令 (`mqtt:listen`) 消費
### 2.3 前端
* **前端視圖 (View)**Laravel Blade * **前端視圖 (View)**Laravel Blade
* **前端互動 (JS)**Alpine.js (專注於行為,不負責渲染) * **前端互動 (JS)**Alpine.js (專注於行為,不負責渲染)
* **介面與樣式 (CSS)**Tailwind CSS + Preline UI (直接寫作於 Blade 模板中)。 * **介面與樣式 (CSS)**Tailwind CSS + Preline UI (直接寫作於 Blade 模板中)。
* **重要規範**Preline UI 僅作為「原子組件」與「JS 互動邏輯」的參考庫。整體的「佈局」與「美學」必須嚴格遵守「極簡奢華風 UI 實作規範 (SKILL.md)」。 * **重要規範**Preline UI 僅作為「原子組件」與「JS 互動邏輯」的參考庫。整體的「佈局」與「美學」必須嚴格遵守「極簡奢華風 UI 實作規範 (SKILL.md)」。
* **前端建置工具**Vite * **前端建置工具**Vite
* **資料庫**MySQL 8.0
* **開發環境**Laravel Sail (Docker / WSL2)
## 3. 目錄結構與慣例 ## 3. 目錄結構與慣例
@@ -29,31 +38,111 @@ trigger: always_on
* **Routes**`routes/web.php` 用於後台管理介面;`routes/api.php` 提供外部或機台調用介面 (需 V1 版本化)。 * **Routes**`routes/web.php` 用於後台管理介面;`routes/api.php` 提供外部或機台調用介面 (需 V1 版本化)。
* **Services** (建議)`app/Services/{Domain}/`,將商業邏輯與資料異動封裝於 Service 中。 * **Services** (建議)`app/Services/{Domain}/`,將商業邏輯與資料異動封裝於 Service 中。
* **Traits**`app/Traits/ApiResponse.php` 用於統一 API JSON 回傳格式。 * **Traits**`app/Traits/ApiResponse.php` 用於統一 API JSON 回傳格式。
* **Jobs**`app/Jobs/{Domain}/`**高併發 IoT 場景之必要實作**。所有日誌、心跳上報必須進入 Redis Queue 進行背景異步處理,嚴禁在 API 直連 DB 寫入日誌 * **Jobs**`app/Jobs/{Domain}/`用於異步處理 IoT 資料寫入、通知發送等背景任務
* **Console Commands**`app/Console/Commands/`,包含 MQTT 橋接守護進程 (`mqtt:listen`) 等常駐指令。
### 3.2 前端 (Blade / Tailwind / Alpine) ### 3.2 前端 (Blade / Tailwind / Alpine)
* **Views (頁面)**:位於 `resources/views/`。通常依功能建立資料夾(如 `resources/views/admin/machines/index.blade.php`)。 * **Views (頁面)**:位於 `resources/views/`。通常依功能建立資料夾(如 `resources/views/admin/machines/index.blade.php`)。
* **Layouts (版面)**:位於 `resources/views/layouts/`。定義全站的共用版面結構(如 header, sidebar, footer * **Layouts (版面)**:位於 `resources/views/layouts/`。定義全站的共用版面結構(如 header, sidebar, footer
* **Components (組件)**:位於 `resources/views/components/`。封裝可重用的 Blade 元件(如 Button, Modal, Table支援透過 `<x-button>` 語法呼叫。 * **Components (組件)**:位於 `resources/views/components/`。封裝可重用的 Blade 元件(如 Button, Modal, Table支援透過 `<x-button>` 語法呼叫。
## 4. 開發標準 (Coding Standards) ### 3.3 MQTT Gateway (Go)
Go 專案以 Monorepo 形式置於專案根目錄下的 `mqtt-gateway/` 資料夾,獨立於 Laravel 程式碼:
* **進入點**`mqtt-gateway/main.go`
* **模組管理**`mqtt-gateway/go.mod` / `go.sum`
* **內部分層**
* `mqtt-gateway/internal/handler/` — 各 Topic 的訊息處理邏輯(如 heartbeat、transaction、error
* `mqtt-gateway/internal/bridge/` — Redis 橋接層,負責將處理後的 JSON 推入 `mqtt_incoming_jobs` List。
* `mqtt-gateway/config/` — 環境變數與 EMQX / Redis 連線設定。
> [!CAUTION]
> Go Gateway 的職責僅限於「接收、驗證、轉發」。**嚴禁**在 Go 中實作任何商業邏輯(如庫存扣減、通知發送),所有業務處理必須統一在 Laravel Service 層完成。
## 4. IoT 通訊架構 (MQTT + HTTP 雙軌制)
本系統的機台通訊採用 **MQTT 與 HTTP 雙軌並行** 的策略,依據通訊特性選擇最適合的協議。
### 4.1 整體資料流向
```
機台 (Android APP)
├─ [高頻/即時] MQTT 長連線 ──→ EMQX Broker ──→ Go Gateway ──→ Redis List ──→ Laravel mqtt:listen ──→ Job ──→ MySQL
└─ [低頻/大檔] HTTP REST ──→ Laravel API Controller ──→ (必要時) Job ──→ MySQL
```
### 4.2 MQTT 通訊端點 (高頻與事件驅動)
以下端點因高頻率或即時性需求,採用 MQTT 協議通訊:
| API 代碼 | Topic 格式 | 用途 | QoS |
| :--- | :--- | :--- | :--- |
| B010 | `machine/{serial_no}/heartbeat` | 心跳上報 (每 10 秒) | 0 |
| B013 | `machine/{serial_no}/error` | 故障與異常狀態上報 | 1 |
| B600 | `machine/{serial_no}/transaction` | 交易紀錄回傳 | 1 |
**雲端→機台指令下發**:透過 `machine/{serial_no}/command` Topic 推送,取代原本 B010 Response 中的 `status` 欄位輪詢機制,實現毫秒級即時指令。
### 4.3 HTTP 通訊端點 (資料拉取與敏感操作)
以下端點因資料量大、安全性要求高或為 Request/Response 模式,維持使用 HTTP REST API
| API 代碼 | 用途 | 維持 HTTP 的原因 |
| :--- | :--- | :--- |
| B000 | 維運人員登入 | 無狀態認證HTTP 更自然 |
| B012 | 商品配置同步 | 大量資料的 GET 拉取 |
| B014 | 金鑰與參數下載 | 高安全性敏感操作,需嚴格 RBAC |
| B009 | 貨道庫存回報 | 低頻操作,由維運人員觸發 |
### 4.4 Redis 橋接機制 (Go ↔ Laravel)
Go Gateway 與 Laravel 之間透過 Redis List 進行單向異步橋接:
* **Redis Key**`mqtt_incoming_jobs`
* **Go 端 (生產者)**:將 MQTT 收到的 Payload 包裝成標準 JSON 後,執行 `RPUSH mqtt_incoming_jobs {json}`
* **Laravel 端 (消費者)**:常駐指令 `php artisan mqtt:listen` 持續執行 `BLPOP mqtt_incoming_jobs`,取得 JSON 後解碼並分派至對應的 Laravel Job (如 `ProcessHeartbeat`, `ProcessTransaction`)。
**JSON 橋接格式規範**
```json
{
"type": "heartbeat",
"serial_no": "M-001",
"received_at": "2026-04-14T09:00:00+08:00",
"payload": {
"current_page": 1,
"firmware_version": "1.0.5",
"temperature": 25.5
}
}
```
> [!IMPORTANT]
> **為何不讓 Go 直接寫入 Laravel Queue** 因為 Laravel Queue 的 Payload 包含 PHP 序列化物件字串 (`serialize()`)Go 無法安全產生此格式。透過獨立的 Redis List + 純 JSON可徹底解耦兩端的技術依賴。
### 4.5 MQTT 連線認證
機台連線 EMQX 時,使用 `serial_no` 作為 Username、`api_token` 作為 Password。驗證流程
1. **Laravel 端 (Token 派發時)**B014 下發 `api_token` 時,同步執行 `Redis::set("machine_auth:{serial_no}", hash(api_token))`
2. **EMQX 端 (連線驗證時)**:配置 Redis Auth Plugin直接查詢 Redis 進行極速驗證 (毫秒級),不經過 MySQL。
3. **Token 更新/撤銷時**Laravel 更新或刪除機台 Token 時,必須同步更新或刪除 Redis 中的對應快取。
## 5. 開發標準 (Coding Standards)
* **命名規範** * **命名規範**
* Controllers: `PascalCaseController.php` (例如 `MachineController.php`) * Controllers: `PascalCaseController.php` (例如 `MachineController.php`)
* Models: `PascalCase.php` (例如 `Machine.php`) * Models: `PascalCase.php` (例如 `Machine.php`)
* Blade Views: `kebab-case.blade.php` 或按資源名稱 (例如 `index.blade.php`, `create.blade.php`) * Blade Views: `kebab-case.blade.php` 或按資源名稱 (例如 `index.blade.php`, `create.blade.php`)
* Routes uri: `kebab-case` (例如 `/machine-logs`) * Routes uri: `kebab-case` (例如 `/machine-logs`)
* Go 檔案: `snake_case.go` (例如 `heartbeat_handler.go`)
* **回傳格式** * **回傳格式**
* Web 路由:回傳 `view()`,表單驗證失敗時直接使用 Laravel 內建的 redirect with errors。 * Web 路由:回傳 `view()`,表單驗證失敗時直接使用 Laravel 內建的 redirect with errors。
* API 路由:回傳標準 JSON 格式的 `JsonResponse` * API 路由:回傳標準 JSON 格式的 `JsonResponse`
## 5. UI 與前端開發指南 ## 6. UI 與前端開發指南
* **樣式撰寫**:全面使用 Tailwind CSS utility classes**避免撰寫自訂 CSS**(除非少數特定動畫或覆寫)。 * **樣式撰寫**:全面使用 Tailwind CSS utility classes**避免撰寫自訂 CSS**(除非少數特定動畫或覆寫)。
* **UI 元件庫**:遵循 **Preline UI** 的類別與 HTML 結構進行開發。 * **UI 元件庫**:遵循 **Preline UI** 的類別與 HTML 結構進行開發。
* **前端腳本** * **前端腳本**
* 優先使用 **Alpine.js** (`x-data`, `x-show`, `@click` 等) 在 HTML 標籤內完成簡單的 DOM 狀態切換與互動邏輯。 * 優先使用 **Alpine.js** (`x-data`, `x-show`, `@click` 等) 在 HTML 標籤內完成簡單的 DOM 狀態切換與互動邏輯。
* 避免在 Blade 內撰寫冗長的 `<script>` Vanilla JS若邏輯過於複雜可將 Alpine state 獨立成 js 檔案再於 Vite 引入,但原則上保持輕量。 * 避免在 Blade 內撰寫冗長的 `<script>` Vanilla JS若邏輯過於複雜可將 Alpine state 獨立成 js 檔案再於 Vite 引入,但原則上保持輕量。
## 6. 多語系 I18n 規範 (Multi-language Standards) ## 7. 多語系 I18n 規範 (Multi-language Standards)
* **視圖開發**:所有使用者可見的文字、按鈕、提示訊息,必須使用 Laravel 的 `@lang('key')``__('key')` 函式包裹。 * **視圖開發**:所有使用者可見的文字、按鈕、提示訊息,必須使用 Laravel 的 `@lang('key')``__('key')` 函式包裹。
* **語系 Key 命名**:語系 Key 必須採用 **英文原始詞彙 (English phrases)** 作為 Key 名稱為原則,以提高代碼可讀性並作為預設回退(除非該字串過長,才建議使用點號分隔的 key * **語系 Key 命名**:語系 Key 必須採用 **英文原始詞彙 (English phrases)** 作為 Key 名稱為原則,以提高代碼可讀性並作為預設回退(除非該字串過長,才建議使用點號分隔的 key
* 範例:使用 `__('Account Settings')` * 範例:使用 `__('Account Settings')`
@@ -61,7 +150,7 @@ trigger: always_on
* 主語系檔案位於 `lang/` 目錄。 * 主語系檔案位於 `lang/` 目錄。
* 開發新功能時,必須同步更新以下三個 JSON 翻譯檔:`zh_TW.json` (主要)、`en.json` (預設)、`ja.json` (日文)。 * 開發新功能時,必須同步更新以下三個 JSON 翻譯檔:`zh_TW.json` (主要)、`en.json` (預設)、`ja.json` (日文)。
## 7. AI 協作規則 (給 Antigravity AI) ## 8. AI 協作規則 (給 Antigravity AI)
* **角色設定**:你是一位專業的全端開發工程師助手。 * **角色設定**:你是一位專業的全端開發工程師助手。
* **代碼生成指令** * **代碼生成指令**
* 所有的解釋說明請使用 **繁體中文**。 * 所有的解釋說明請使用 **繁體中文**。
@@ -70,23 +159,24 @@ trigger: always_on
* **【多語系強制要求】** 任何新增的 Blade UI 區塊,禁止硬編碼 (Hard-coded) 中文或英文。必須使用 `__('...')` 並同步在 `lang/*.json` 補上翻譯。 * **【多語系強制要求】** 任何新增的 Blade UI 區塊,禁止硬編碼 (Hard-coded) 中文或英文。必須使用 `__('...')` 並同步在 `lang/*.json` 補上翻譯。
* 生成 UI 區塊時,必須優先參考與產生 **Preline UI** 風格與結構的標記語法。 * 生成 UI 區塊時,必須優先參考與產生 **Preline UI** 風格與結構的標記語法。
* 開發新功能時,請建立標準的 Controller 搭配對應的 `resources/views/.../` 目錄。 * 開發新功能時,請建立標準的 Controller 搭配對應的 `resources/views/.../` 目錄。
* **【Go Gateway 開發】** 修改 `mqtt-gateway/` 內的 Go 程式碼時嚴禁加入商業邏輯。Go 僅負責訊息接收、格式轉換與 Redis 轉發。
## 8. 運行機制 (Docker / Sail) ## 9. 運行機制 (Docker / Sail)
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令: 由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
* **啟動環境**`./vendor/bin/sail up -d` * **啟動環境**`./vendor/bin/sail up -d`(將同時啟動 Laravel、MySQL、Redis、EMQX、Go Gateway
* **執行 PHP 指令**`./vendor/bin/sail php -v` * **執行 PHP 指令**`./vendor/bin/sail php -v`
* **執行 Artisan 指令**`./vendor/bin/sail artisan route:list` * **執行 Artisan 指令**`./vendor/bin/sail artisan route:list`
* **執行 Composer**`./vendor/bin/sail composer install` * **執行 Composer**`./vendor/bin/sail composer install`
* **執行 Node/NPM**`./vendor/bin/sail npm run dev` * **執行 Node/NPM**`./vendor/bin/sail npm run dev`
## 8. 部署與查修環境 (CI/CD) ## 10. 部署與查修環境 (CI/CD)
* **自動化部署**:專案具備基於 Gitea Actions 的 CI/CD 自動化部署流程 (`.gitea/workflows/`)。 * **自動化部署**:專案具備基於 Gitea Actions 的 CI/CD 自動化部署流程 (`.gitea/workflows/`)。
* **Demo 環境 (對應 `demo` 分支)** * **Demo 環境 (對應 `demo` 分支)**
* 透過 `deploy-demo.yaml`,合併或推送到 `demo` 分支會自動部署至 `demo-cloud.taiwan-star.com.tw` * 透過 `deploy-demo.yaml`,合併或推送到 `demo` 分支會自動部署至 `demo-cloud.taiwan-star.com.tw`
* 登入伺服器查修:`ssh gitea_work`,路徑為 `/var/www/star-cloud-demo` * 登入伺服器查修:`ssh gitea_work`,路徑為 `/var/www/star-cloud-demo`
## 9. 瀏覽器測試規範 (Browser Testing) ## 11. 瀏覽器測試規範 (Browser Testing)
當需要進行瀏覽器自動化測試或手動驗證時,必須遵守以下連線資訊: 當需要進行瀏覽器自動化測試或手動驗證時,必須遵守以下連線資訊:
* **本地測試網址**`http://localhost:8090/` (注意:非 8000 或 8080) * **本地測試網址**`http://localhost:8090/` (注意:非 8000 或 8080)

View File

@@ -14,7 +14,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
| 觸發詞 / 情境 | 對應 Skill | 路徑 | | 觸發詞 / 情境 | 對應 Skill | 路徑 |
|---|---|---| |---|---|---|
| 機台通訊, IoT, 日誌上報, Log Ingestion, 異步隊列, Queue, Heartbeat, 心跳發報 | **IoT 通訊與高併發處理規範** | `.agents/skills/iot-communication/SKILL.md` | | 機台通訊, IoT, 日誌上報, Log Ingestion, 異步隊列, Queue, Heartbeat, 心跳發報, MQTT, Topic, Broker, EMQX | **IoT 通訊與高併發處理規範 / MQTT 通訊規範** | `.agents/skills/iot-communication/SKILL.md` <br> `.agents/skills/mqtt-communication-specs/SKILL.md` |
| B010, B017, B600, B055, API 規格, 通訊協議, 狀態碼, 頁面碼, 範例, JSON | **API 技術規格與通訊協議規範** | `.agents/skills/api-technical-specs/SKILL.md` | | B010, B017, B600, B055, API 規格, 通訊協議, 狀態碼, 頁面碼, 範例, JSON | **API 技術規格與通訊協議規範** | `.agents/skills/api-technical-specs/SKILL.md` |
| 介面, UI, 設計, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫, 畫面, 頁面 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/SKILL.md` | | 介面, UI, 設計, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫, 畫面, 頁面 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/SKILL.md` |
| 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` | | 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` |

View File

@@ -8,12 +8,22 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
本文件集中定義所有機台與雲端通訊的 API 規格,確保硬體端與軟體端在資料交換格式與業務定義上保持完全一致。 本文件集中定義所有機台與雲端通訊的 API 規格,確保硬體端與軟體端在資料交換格式與業務定義上保持完全一致。
## 1. 核心命名原則 ## 1. 核心命名原則
- **語意化優先**:捨棄舊版 `M_` 前綴,統一使用 snake_case (如 `firmware_version`)。 - **語意化優先**:捨棄舊版 M_ 前綴,統一使用 snake_case (如 firmware_version)。
- **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。 - **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。
## 2. 身份認證 (Authentication) ## 2. 身份認證 (Authentication)
- **Bearer Token**:所有 API 必須在 Header 帶入 `Authorization: Bearer <api_token>` 本系統採用兩階段認證模式:
- **身分綁定**:後端透過 Token 自動識別 `machine_id`,禁止在 Body 帶入 `machine``key` 欄位。
### 2.1 維運人員認證 (User Authentication)
- **核發端點**B000 (登入)。
- **使用端點**B014 (參數下載)。
- **方式**:使用 Laravel Sanctum 核發之 **User Token**
- **Header**`Authorization: Bearer <user_token>`
### 2.2 機台通訊認證 (Machine Authentication)
- **適用 API**B010, B012, B013, B600 等後續通訊。
- **方式**:使用機台專屬之 **api_token**
- **Header**`Authorization: Bearer <api_token>`
--- ---
@@ -22,76 +32,229 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
### 3.1 B000: 機台本地管理員同步登入 ### 3.1 B000: 機台本地管理員同步登入
用於機台 Android 端維護人員登入與進入設定頁。此 API 無狀態,且為例外不強制檢查 Bearer Token 的端點。 用於機台 Android 端維護人員登入與進入設定頁。此 API 無狀態,且為例外不強制檢查 Bearer Token 的端點。
- **URL**: `POST /api/v1/app/admin/login/B000` - **URL**: POST /api/v1/app/admin/login/B000
- **Request Body:** - **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 | | 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| `machine` | String | 是 | 機台編號 (serial_no) | `M-001` | | machine | String | 是 | 機台編號 (serial_no) | M-001 |
| `Su_Account` | String | 是 | 系統管理員或公司管理員帳號 | `admin` | | Su_Account | String | 是 | 系統管理員或公司管理員帳號 | admin |
| `Su_Password` | String | 是 | 密碼 | `password123` | | Su_Password | String | 是 | 密碼 | password123 |
| `ip` | String | 否 | 用戶端 IP (相容舊版) | `192.168.1.100` | | ip | String | 否 | 用戶端 IP (相容舊版) | 192.168.1.100 |
| `type` | String | 否 | 裝置類型代碼 (相容舊版) | `2` | | type | String | 否 | 裝置類型代碼 (相容舊版) | 2 |
- **Response Body:** - **Response Body:**
> [!IMPORTANT] > [!IMPORTANT]
> 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 `Success` > 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 Success。
| 參數 | 類型 | 說明 | 範例 | | 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| `message` | String | 驗證結果 (`Success``Failed`) | `Success` | | message | String | 驗證結果 (Success 或 Failed) | Success |
| token | String | **臨時身份認證 Token** (用於 B014) | 1|abcdefg... |
--- ---
### 3.2 B010: 心跳上報與狀態同步 ### 3.2 B005: 廣告清單同步
用於確認機台在線狀態、更新感測數據、提交事件日誌並獲取雲端指令 用於機台端獲取目前應播放的廣告檔案 URL 清單
- **URL**: `POST /api/v1/app/machine/status/B010` - **URL**: GET /api/v1/app/machine/ad/B005
- **Request Body:** - **Request Body:** 無 (GET 請求)
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| `current_page` | Integer | 是 | 當前頁面代碼 (見下表) | `1` |
| `firmware_version` | String | 是 | 韌體版本號 | `1.0.5` |
| `model` | String | 是 | 機台型號 | `STAR-V1` |
| `temperature` | Float | 否 | 環境溫度 | `25.5` |
| `door_status` | Integer | 否 | 門狀態 (0:關 / 1:開) | `0` |
| `log` | String | 否 | 事件日誌簡述 | `Door opened` |
| `log_level` | String | 否 | info, warn, error | `info` |
| `log_payload` | Object | 否 | 額外日誌 JSON 對象 | `{"code":500}` |
- **Response Body:** - **Response Body:**
| 參數 | 類型 | 說明 | 範例 | | 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| `success` | Boolean | 請求是否處理成功 | `true` | | success | Boolean | 請求是否成功 | true |
| `code` | Integer | 內部業務狀態碼 | `200` | | code | Integer | 內部業務狀態碼 | 200 |
| `message` | String | 回應訊息 | `OK` | | data | Array | 廣告物件陣列 | [{"t070v04": "https://..."}] |
| `status` | String | **雲端指令代碼** (見下表) | `49` |
**data 陣列內部欄位:**
- t070v01: 廣告名稱 (Name)
- t070v02: 播放長度 (Duration) — 秒數,若後台未設定,預設為 15 秒。
- t070v03: 廣告位置 (Position/Flag) — (3: 待機廣告, 1: 販賣頁, 2: 來店禮)。
- t070v04: 廣告 URL。
- t070v05: 播放順位 (Sort Order)。
---
### 3.3 B009: 貨道庫存即時回報 (Supplementary Report)
當維修或補貨人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。
#### B009 權限驗證邏輯 (RBAC Compliance)
系統會依據 account 欄位進行三層式權限核查:
1. **系統層 (System Admin)**:當 company_id 為 null 時,具備全局管理權限,直接放行。
2. **公司層 (Company Admin)**:當 is_admin 為 true 時,檢查機台的 company_id 是否與該帳號一致。
3. **人員層 (Operator/User)**:當帳號僅為一般人員時,檢查 machine_user 授權表,確認該帳號有被分配至此機台。
- **URL**: PUT /api/v1/app/products/supplementary/B009
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| account | String | 是 | 操作人員帳號 | 0999123456 |
| data | Array | 是 | 貨道數據陣列 | [{"tid":"1", "t060v00":"1", "num":"10"}] |
- **data 陣列內部欄位:**
| 欄位 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| tid | Integer | 貨道編號 (Slot No) | 1 |
| t060v00 | String | 商品資料庫 ID (或是 Barcode) | "1" |
| num | Integer | 實體剩餘庫存數量 | 10 |
| type | Integer | 貨道物理類型 (1: 履帶, 2: 彈簧)。若未提供,預設為 1。 | 1 |
> [!TIP]
> **自動化上限同步邏輯**
> 當後端收到 B009 時,會根據 type 自動從該商品的配置中選取 spring_limit 或 track_limit 並自動更新該貨道的 max_stock 欄位。機台端無需手動計算上限。
- **Response Body (Success 200):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 同步是否成功 | true |
| code | Integer | 內部業務狀態碼 | 200 |
| message | String | 回應訊息 | Slot report synchronized success |
| status | String | 固定回傳 49 代表同步完成 | "49" |
---
### 3.4 B010: 心跳上報與狀態同步
用於確認機台在線狀態、更新感測數據、提交事件日誌並獲取雲端指令。
- **URL**: POST /api/v1/app/machine/status/B010
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| current_page | Integer | 是 | 當前頁面代碼 (見下表) | 1 |
| firmware_version | String | 是 | 韌體版本號 | 1.0.5 |
| model | String | 是 | 機台型號 | STAR-V1 |
| temperature | Float | 否 | 環境溫度 | 25.5 |
| door_status | Integer | 否 | 門狀態 (0:關 / 1:開) | 0 |
| log | String | 否 | 事件日誌簡述 | Door opened |
| log_level | String | 否 | info, warn, error | info |
| log_payload | Object | 否 | 額外日誌 JSON 對象 | {"code":500} |
- **Response Body:**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否處理成功 | true |
| code | Integer | 內部業務狀態碼 | 200 |
| message | String | 回應訊息 | OK |
| status | String | **雲端指令代碼** (見下表) | 49 |
#### B010 代碼對照表 #### B010 代碼對照表
**頁面代碼 (current_page)** **頁面代碼 (current_page)**
- `0`: 離線 / `1`: 主頁面 / `2`: 販賣頁 / `3`: 管理頁 - 0: 離線 / 1: 主頁面 / 2: 販賣頁 / 3: 管理頁
- `4`: 補貨頁 / `5`: 教學頁 / `6`: 購買中 / `7`: 鎖定頁 - 4: 補貨頁 / 5: 教學頁 / 6: 購買中 / 7: 鎖定頁
- `60`: 出貨成功 / `61`: 貨道測試 / `62`: 付款選擇 - 60: 出貨成功 / 61: 貨道測試 / 62: 付款選擇
- `63`: 等待付款 / `64`: 出貨 / `65`: 收據簽單 - 63: 等待付款 / 64: 出貨 / 65: 收據簽單
- `66`: 通行碼 / `67`: 取貨碼 / `68`: 訊息顯示 - 66: 通行碼 / 67: 取貨碼 / 68: 訊息顯示
- `69`: 取消購買 / `610`: 購買結束 / `611`: 來店禮 - 69: 取消購買 / 610: 購買結束 / 611: 來店禮
- `612`: 出貨失敗 - 612: 出貨失敗
**雲端指令代碼 (status)** **雲端指令代碼 (status)**
- `49`: reload B017 (貨道同步) - 49: reload B017 (貨道同步)
- `51`: reboot (重啟系統) - 51: reboot (重啟系統)
- `60`: reboot card machine (刷卡機重啟) - 60: reboot card machine (刷卡機重啟)
- `61`: checkout (觸發結帳) - 61: checkout (觸發結帳)
- `70`: unlock (解鎖) - 70: unlock (解鎖)
- `71`: lock (鎖定) - 71: lock (鎖定)
- `85`: reload B0552 (遠端出貨) - 85: reload B0552 (遠端出貨)
- `待定義`: change (遠端找零 - 註:指令中心有此功能,但目前 Java App 尚無對接對應的連動事件)
--- ---
### 3.3 B017: 貨道與庫存同步 (規劃中) ### 3.5 B012: 商品配置與商品主檔同步 (Unified Sync)
- **URL**: `POST /api/v1/app/machine/reload_msg/B017` 用於機台端獲取目前所有可販售商品的詳細配置。App 端應依據呼叫的方法決定數據處理方式。
- 說明:當機台收到 B010 回應 `status: 49` 時,應呼叫此 API 同步最新貨道佈局。
### 3.4 B600: 交易數據回傳 (規劃中) - **URL**: GET|PATCH /api/v1/app/machine/products/B012
- **URL**: `POST /api/v1/app/B600` - **Authentication**: Bearer Token (Header)
- 說明:交易完成後提交支付方式、金額、商品與出貨結果。 - **Request Body:** 無 (由 Token 自動識別機台)
#### 運作邏輯 (Client-side Logic):
- **GET**:執行 **全量同步**。App 應於收到成功回應後,先執行 deleteAll() 再進行 insertAll() 以確保與伺服器完全一致。
- **PATCH**:執行 **增量更新**。App 於收到成功回應後,僅對記憶體中的既存商品進行欄位值覆蓋 (Patching)。
| 欄位 | 型別 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| t060v00 | String | 商品資料庫 ID | "1" |
| t060v01 | String | 商品名稱 | 可口可樂 330ml |
| t060v01_en | String | 英文名稱 | Coca Cola |
| t060v01_jp | String | 日文名稱 | コカコーラ |
| t060v03 | String | 商品規格/簡述 | Cold Drink |
| t060v06 | String | 圖片 URL | https://.../coke.png |
| t060v09 | Float | 標準零售價 | 25.0 |
| t060v11 | Integer | **貨道庫存上限** (預設履帶) | 10 |
| t060v30 | Float | 會員價 | 20.0 |
| t063v03 | Float | 本機銷售價格 (同定價) | 25.0 |
| t060v40 | String | 行銷計畫 (Marketing Plan) | Buy 1 Get 1 |
| t060v41 | String | 物料編碼 (Material Code) | SKU-001 |
| spring_limit | Integer | **彈簧貨道上限** (建議使用此欄位) | 10 |
| track_limit | Integer | **履帶貨道上限** (建議使用此欄位) | 15 |
---
### 3.6 B013: 機台故障與異常狀態上報 (Error/Status Report)
用於接收機台發出的即時硬體狀態代碼(如卡貨、門未關),並自動由雲端後端翻譯為易讀日誌。
- **URL**: POST /api/v1/app/machine/error/B013
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| tid | Integer | 否 | 涉及之貨道編號 (Slot No) | 12 |
| error_code | String | 是 | 硬體狀態代碼 (4 位 16 進位) | "0403" |
- **回應 (Success 202):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求已接收 | true |
| code | Integer | 200 | 200 |
#### B013 硬體代碼對照表 (由 MachineService 自動翻譯)
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
| :--- | :--- | :--- | :--- |
| **0402** | Dispense successful | info | 出貨成功 |
| **0403** | Slot jammed | error | **貨道卡貨** (重大異常) |
| **0202** | Product empty | warning | 貨道缺貨 |
| **0412** | Elevator rise error | error | 昇降機上升異常 |
| **0415** | Pickup door error | error | 取貨門異常 |
| **5402** | Pickup door not closed | warning | **取貨門未關** (警告) |
| **5403** | Elevator failure | error | 昇降系統故障 |
---
### 3.7 B014: 機台參數與金鑰下載 (Config Download)
用於機台引導階段 (Provisioning),向雲端請求支付金鑰、發票設定及機台正式 API Token。
- **URL**: POST /api/v1/app/machine/setting/B014
- **Authentication**: **User Token** (Sanctum Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
- **Response Body (Success 200):**
| 欄位 (Key) | 說明 | 備註 |
| :--- | :--- | :--- |
| **t050v01** | 機台序號 | 即 machine_id |
| **api_token** | **機台正式 Token** | 初始化後應存於本地,後續 API 認證用 |
| **t050v41** | 玉山特店編號 | ESUN Merchant ID |
| **t050v42** | 玉山終端編號 | ESUN Terminal ID |
| **t050v43** | 玉山 Hash Key | ESUN Hash |
| **t050v34** | 發票特店 ID | Invoice Merchant ID |
| **t050v35** | 發票 Hash Key | Invoice Key |
| **t050v36** | 發票 Hash IV | Invoice IV |
| **TP_APP_ID** | 趨勢支付 AppID | TrendPay ID |
| **TP_APP_KEY** | 趨勢支付 Key | TrendPay Key |
> [!CAUTION]
> **安全性規範**B014 會回傳敏感金鑰與正式 Token背景必須強制進行 RBAC 校驗。只有當前登入的人員具備該機台管理權限時,後端才允許發放資料。

View File

@@ -9,13 +9,25 @@ description: 規範智能販賣機與 Cloud 平台間的高頻通訊處理流程
## 1. 處理管線 (Processing Pipeline) ## 1. 處理管線 (Processing Pipeline)
所有來自機台的非即時性資料(日誌、心跳、狀態上報)必須遵循以下 pipeline 所有來自機台的非即時性資料(日誌、心跳、狀態上報)必須遵循以下 pipeline。依據通訊協議不同,進入點有兩條路徑
### 1.1 HTTP 管線 (低頻/大檔操作)
適用於 B000, B009, B012, B014 等低頻或大資料量的端點:
1. **API Controller (接收層)**:驗證 Request 合法性,隨即分派 (Dispatch) 任務至 Queue並回傳 `202 Accepted` 1. **API Controller (接收層)**:驗證 Request 合法性,隨即分派 (Dispatch) 任務至 Queue並回傳 `202 Accepted`
2. **Job (異步層)**:由背景 Worker 讀取隊列任務,呼叫對應 Service 處理。 2. **Job (異步層)**:由背景 Worker 讀取隊列任務,呼叫對應 Service 處理。
3. **Service (邏輯層)**:封裝商業邏輯,更新資料庫。 3. **Service (邏輯層)**:封裝商業邏輯,更新資料庫。
4. **Model (儲存層)**:執行資料存取。 4. **Model (儲存層)**:執行資料存取。
### 1.2 MQTT 管線 (高頻/即時操作)
適用於 B010 (心跳), B013 (異常), B600 (交易) 等高頻或即時性端點:
1. **Go Gateway (接收層)**:訂閱 EMQX Topic提取 `serial_no`,包裝成標準 JSON。
2. **Redis List (橋接層)**Go 執行 `RPUSH mqtt_incoming_jobs {json}`
3. **Laravel `mqtt:listen` (消費層)**:常駐指令 `BLPOP` 取出 JSON根據 `type` 分派至對應 Job。
4. **Job ➜ Service ➜ Model**:與 HTTP 管線後半段相同。
> [!TIP]
> 兩條管線的 **Job / Service / Model 層完全共用**,差異僅在「進入點」。這確保了業務邏輯不會因為通訊協議不同而分裂。
> [!IMPORTANT] > [!IMPORTANT]
> **嚴禁**在 API Controller 直接進行資料庫寫入操作(針對機台發訊端點)。 > **嚴禁**在 API Controller 直接進行資料庫寫入操作(針對機台發訊端點)。
@@ -53,9 +65,14 @@ public function handle(MachineService $service): void
## 4. 速率限制 (Rate Limiting) ## 4. 速率限制 (Rate Limiting)
- 所有的 IoT API 必須在 `routes/api.php` 中使用 `throttle:api` 或自定義 Middleware。 ### 4.1 HTTP 端點
- 所有的 IoT HTTP API 必須在 `routes/api.php` 中使用 `throttle:api` 或自定義 Middleware。
- 針對單一機台 ID 應限制其每一分鐘的最高連線數,防止遭受攻擊或機台 Bug 導致的連線暴衝。 - 針對單一機台 ID 應限制其每一分鐘的最高連線數,防止遭受攻擊或機台 Bug 導致的連線暴衝。
### 4.2 MQTT 端點
- 限速由 **EMQX Broker** 的 Rate Limiting 功能負責(非 Laravel Middleware
- Go Gateway 層可額外實作簡易的 Token Bucket當某台機台每秒超過閾值時丟棄訊息並記錄 Warning Log。
## 5. 檢核項目 (Checklist) ## 5. 檢核項目 (Checklist)
- [ ] 是否使用了 `ApiResponse` Trait - [ ] 是否使用了 `ApiResponse` Trait
- [ ] 業務邏輯是否已封裝至 `App\Services` - [ ] 業務邏輯是否已封裝至 `App\Services`
@@ -64,13 +81,16 @@ public function handle(MachineService $service): void
## 6. API 規格定義 (API Specifications) ## 6. API 規格定義 (API Specifications)
> [!IMPORTANT] > [!IMPORTANT]
> **規格分離原則**:本技能僅規範「通訊處理邏輯」。關於具體的 API 欄位、參數命名、狀態代碼對照與範例,請務必參閱專屬技能規範: > **規格分離原則**:本技能僅規範「通訊處理邏輯」。關於具體的欄位定義與資料格式,請參閱對應的專屬技能規範:
> - **[API 技術規格與通訊協議規範](file:///home/mama/projects/star-cloud/.agents/skills/api-technical-specs/SKILL.md)** > - **HTTP 端點**[API 技術規格與通訊協議規範](file:///home/mama/projects/star-cloud/.agents/skills/api-technical-specs/SKILL.md)
> - **MQTT 端點**[MQTT 即時通訊與 Topic 規範](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md)
### 常見端點處理模式 ### 常見端點處理模式
1. **B010 (心跳)**:高頻點,必須進入 Redis Queue。更新 `last_heartbeat_at` 與感測器快照。 1. **B010 (心跳)**:高頻點,**MQTT 管線** (`machine/+/heartbeat`)。更新 `last_heard_at` 與感測器快照。
2. **B600 (交易)**:高價值點,必須進入任務隊列並支援重試。建立 `Transaction` 紀錄 2. **B013 (異常)**:事件驅動點,走 **MQTT 管線** (`machine/+/error`)。寫入 `machine_logs` 並觸發告警
3. **B017 (貨道)**:回覆較大資料量,應確保 Service 層具備緩存 (Cache) 機制 3. **B600 (交易)**:高價值點,走 **MQTT 管線** (`machine/+/transaction`)。建立 `Transaction` 紀錄並支援重試
4. **B012 (商品同步)**:大資料量,走 **HTTP 管線**。應確保 Service 層具備緩存 (Cache) 機制。
5. **B055 (遠端出貨)**:雲端下發指令,走 **MQTT 下行管線** (`machine/{id}/command`)。
--- ---

View File

@@ -0,0 +1,100 @@
---
name: MQTT 即時通訊與 Topic 規範
description: 定義 Star Cloud 與終端機台 (IoT) 之間的 MQTT 全域通訊拓撲、主題 (Topic) 結構、資料載體 (Payload) 格式與安全性認證機制。
---
# MQTT 即時通訊與 Topic 規範 (MQTT Protocol Specs)
本文件定義機台與 Star Cloud 之間的高併發即時通訊標準。MQTT 主要用於處理高頻率且對即時性要求高的訊息(如心跳、遠端指令),與 HTTP REST API 形成雙軌互補架構。
## 1. 連線基礎設定 (Connection Basics)
### 1.1 Broker 資訊
- **協議版本**MQTT v3.1.1 (相容性最高)
- **預設埠號**1883 (TCP / 測試用), 8883 (SSL/TLS / 正式用)
- **Keep-Alive**:建議設定為 30 ~ 60 秒。
### 1.2 身份認證 (Authentication)
機台連線時必須提供以下憑據:
- **Username**:機台編號 (`serial_no`),例如 `M-001`
- **Password**:機台正式 Token (`api_token`),由 B014 API 取得。
- **Client ID**:建議格式為 `SC_{serial_no}_{random_suffix}`,確保唯一性。
---
## 2. 主題架構 (Topic Topology)
我們採用目錄式的層級結構,方便未來進行萬台設備的管理與 ACL 權限切分。
| 主題名稱 (Topic) | 方向 | QoS | 用途說明 |
| :--- | :--- | :--- | :--- |
| `machine/{serial_no}/heartbeat` | 設備 ➜ 雲端 | 0 | 每 10 秒上報心跳、溫度、目前的頁面碼。 |
| `machine/{serial_no}/error` | 設備 ➜ 雲端 | 1 | 發生硬體故障、卡貨或門未關時立即上報。 |
| `machine/{serial_no}/transaction` | 設備 ➜ 雲端 | 1 | 交易完成、出貨結果的回報。 |
| `machine/{serial_no}/command` | 雲端 ➜ 設備 | 1 | 雲端下發的即時指令(出貨、更新、重啟)。 |
---
## 3. 資料載體規範 (Payload Definitions)
所有 Payload 統一採用 **JSON** 格式,字母一律為 **snake_case**
### 3.1 心跳上報 (Heartbeat) - `machine/{id}/heartbeat`
比照原 B010 邏輯,但去除不必要的 HTTP Header 開銷。
```json
{
"current_page": 1,
"firmware_version": "1.0.5",
"temperature": 25.5,
"door_status": 0,
"timestamp": "2026-04-14T09:00:00+08:00"
}
```
### 3.2 異常上報 (Error/Event) - `machine/{id}/error`
比照原 B013 邏輯。
```json
{
"tid": 12,
"error_code": "0403",
"log": "Slot jammed at slot 12",
"timestamp": "2026-04-14T09:05:00+08:00"
}
```
### 3.3 雲端指令 (Downstream Commands) - `machine/{id}/command`
這是雲端主動下發給機台的訊息,取代原本 B010 Response 的輪詢等待。
```json
{
"command": "dispense",
"payload": {
"slot_no": 5,
"transaction_id": "T202604140001"
},
"message_id": "MSG_123456789"
}
```
**常用指令集:**
- `reboot`: 機台重啟。
- `reload_config`: 重新下載參數 (B014)。
- `reload_products`: 重新同步商品 (B012)。
- `dispense`: 遠端出貨指令 (B055)。
---
## 4. 安全與 QOS 規範
1. **存取控制 (ACL)**EMQX Broker 必須設定 ACL禁止機台 A 訂閱機台 B 的 Topic。機台僅能訂閱與發布包含自身 `serial_no` 的路徑。
2. **QoS 策略**
- **QoS 0**:適用於高頻率心跳,即使掉一兩次包也不影響系統判斷。
- **QoS 1**適用於交易與指令確保「至少送達一次」。App 端收到指令後應回覆回執。
3. **遺囑訊息 (Last Will and Testament)**
機台 Connect 時應設定 Last Will 於 `machine/{serial_no}/heartbeat`Payload 為 `{"status": "offline"}`。當連線異常中斷時,雲端能立刻得知。
---
## 5. 與 REST API 的同步關係
當 MQTT 通訊正常時,機台應停止定時呼叫 B010 HTTP API。若 MQTT 斷線超過 3 分鐘,則退回 (Fallback) 使用 HTTP 輪詢模式以維持基礎通訊。

1
.gitignore vendored
View File

@@ -19,5 +19,6 @@ yarn-error.log
/.vscode /.vscode
/docs/API /docs/API
/docs/*.xlsx /docs/*.xlsx
/docs/pptx

View File

@@ -21,7 +21,9 @@ class AdvertisementController extends AdminController
// Tab 1: 廣告列表 // Tab 1: 廣告列表
$advertisements = Advertisement::with('company')->latest()->paginate(10); $advertisements = Advertisement::with('company')->latest()->paginate(10);
$allAds = Advertisement::active()->get();
// Tab 2: 機台廣告設置 (所需資料) - 隱藏已過期的廣告
$allAds = Advertisement::playing()->get();
// Tab 2: 機台廣告設置 (所需資料) // Tab 2: 機台廣告設置 (所需資料)
// 取得使用者有權限的機台列表 (已透過 Global Scope 過濾) // 取得使用者有權限的機台列表 (已透過 Global Scope 過濾)
@@ -54,6 +56,8 @@ class AdvertisementController extends AdminController
$request->type === 'image' ? 'max:10240' : 'max:51200', // Image 10MB, Video 50MB $request->type === 'image' ? 'max:10240' : 'max:51200', // Image 10MB, Video 50MB
], ],
'company_id' => 'nullable|exists:companies,id', 'company_id' => 'nullable|exists:companies,id',
'start_at' => 'nullable|date',
'end_at' => 'nullable|date|after_or_equal:start_at',
]); ]);
$user = auth()->user(); $user = auth()->user();
@@ -71,13 +75,15 @@ class AdvertisementController extends AdminController
$companyId = $user->company_id; $companyId = $user->company_id;
} }
Advertisement::create([ $advertisement = Advertisement::create([
'company_id' => $companyId, 'company_id' => $companyId,
'name' => $request->name, 'name' => $request->name,
'type' => $request->type, 'type' => $request->type,
'duration' => (int) $request->duration, 'duration' => (int) $request->duration,
'url' => Storage::disk('public')->url($path), 'url' => Storage::disk('public')->url($path),
'is_active' => true, 'is_active' => true,
'start_at' => $request->start_at,
'end_at' => $request->end_at,
]); ]);
if ($request->wantsJson()) { if ($request->wantsJson()) {
@@ -99,6 +105,8 @@ class AdvertisementController extends AdminController
'duration' => 'required|in:15,30,60', 'duration' => 'required|in:15,30,60',
'is_active' => 'boolean', 'is_active' => 'boolean',
'company_id' => 'nullable|exists:companies,id', 'company_id' => 'nullable|exists:companies,id',
'start_at' => 'nullable|date',
'end_at' => 'nullable|date|after_or_equal:start_at',
]; ];
if ($request->hasFile('file')) { if ($request->hasFile('file')) {
@@ -111,7 +119,7 @@ class AdvertisementController extends AdminController
$request->validate($rules); $request->validate($rules);
$data = $request->only(['name', 'type', 'duration']); $data = $request->only(['name', 'type', 'duration', 'start_at', 'end_at']);
$data['is_active'] = $request->has('is_active'); $data['is_active'] = $request->has('is_active');
$user = auth()->user(); $user = auth()->user();
@@ -150,7 +158,7 @@ class AdvertisementController extends AdminController
return redirect()->back()->with('success', __('Advertisement updated successfully.')); return redirect()->back()->with('success', __('Advertisement updated successfully.'));
} }
public function destroy(Advertisement $advertisement) public function destroy(Request $request, Advertisement $advertisement)
{ {
// 檢查是否有機台正投放中 // 檢查是否有機台正投放中
if ($advertisement->machineAdvertisements()->exists()) { if ($advertisement->machineAdvertisements()->exists()) {
@@ -179,6 +187,7 @@ class AdvertisementController extends AdminController
{ {
$assignments = MachineAdvertisement::where('machine_id', $machine->id) $assignments = MachineAdvertisement::where('machine_id', $machine->id)
->with('advertisement') ->with('advertisement')
->orderBy('sort_order', 'asc')
->get() ->get()
->groupBy('position'); ->groupBy('position');

View File

@@ -14,7 +14,8 @@ class CompanyController extends Controller
*/ */
public function index(Request $request) public function index(Request $request)
{ {
$query = Company::query()->withCount(['users', 'machines']); $query = Company::query()->withCount(['users', 'machines'])
->with(['contracts.creator:id,name']);
// 搜尋 // 搜尋
if ($search = $request->input('search')) { if ($search = $request->input('search')) {
@@ -55,6 +56,10 @@ class CompanyController extends Controller
'contact_email' => 'nullable|email|max:255', 'contact_email' => 'nullable|email|max:255',
'start_date' => 'required|date', 'start_date' => 'required|date',
'end_date' => 'nullable|date', 'end_date' => 'nullable|date',
'warranty_start_date' => 'nullable|date',
'warranty_end_date' => 'nullable|date',
'software_start_date' => 'nullable|date',
'software_end_date' => 'nullable|date',
'status' => 'required|boolean', 'status' => 'required|boolean',
'note' => 'nullable|string', 'note' => 'nullable|string',
'settings' => 'nullable|array', 'settings' => 'nullable|array',
@@ -83,11 +88,28 @@ class CompanyController extends Controller
'contact_email' => $validated['contact_email'] ?? null, 'contact_email' => $validated['contact_email'] ?? null,
'start_date' => $validated['start_date'] ?? null, 'start_date' => $validated['start_date'] ?? null,
'end_date' => $validated['end_date'] ?? null, 'end_date' => $validated['end_date'] ?? null,
'warranty_start_date' => $validated['warranty_start_date'] ?? null,
'warranty_end_date' => $validated['warranty_end_date'] ?? null,
'software_start_date' => $validated['software_start_date'] ?? null,
'software_end_date' => $validated['software_end_date'] ?? null,
'status' => $validated['status'], 'status' => $validated['status'],
'note' => $validated['note'] ?? null, 'note' => $validated['note'] ?? null,
'settings' => $validated['settings'] ?? [], 'settings' => $validated['settings'] ?? [],
]); ]);
// 記錄合約歷程
$company->contracts()->create([
'type' => $company->original_type,
'start_date' => $company->start_date,
'end_date' => $company->end_date,
'warranty_start_date' => $company->warranty_start_date,
'warranty_end_date' => $company->warranty_end_date,
'software_start_date' => $company->software_start_date,
'software_end_date' => $company->software_end_date,
'note' => __('Initial contract registration'),
'creator_id' => auth()->id(),
]);
// 如果有填寫帳號資訊,則建立管理員帳號 // 如果有填寫帳號資訊,則建立管理員帳號
if (!empty($validated['admin_username']) && !empty($validated['admin_password'])) { if (!empty($validated['admin_username']) && !empty($validated['admin_password'])) {
$user = \App\Models\System\User::create([ $user = \App\Models\System\User::create([
@@ -143,6 +165,10 @@ class CompanyController extends Controller
'contact_email' => 'nullable|email|max:255', 'contact_email' => 'nullable|email|max:255',
'start_date' => 'required|date', 'start_date' => 'required|date',
'end_date' => 'nullable|date', 'end_date' => 'nullable|date',
'warranty_start_date' => 'nullable|date',
'warranty_end_date' => 'nullable|date',
'software_start_date' => 'nullable|date',
'software_end_date' => 'nullable|date',
'status' => 'required|boolean', 'status' => 'required|boolean',
'note' => 'nullable|string', 'note' => 'nullable|string',
'settings' => 'nullable|array', 'settings' => 'nullable|array',
@@ -154,7 +180,22 @@ class CompanyController extends Controller
$validated['settings']['enable_points'] = filter_var($validated['settings']['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN); $validated['settings']['enable_points'] = filter_var($validated['settings']['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
} }
$company->update($validated); DB::transaction(function () use ($validated, $company) {
$company->update($validated);
// 記錄合約歷程
$company->contracts()->create([
'type' => $company->current_type,
'start_date' => $company->start_date,
'end_date' => $company->end_date,
'warranty_start_date' => $company->warranty_start_date,
'warranty_end_date' => $company->warranty_end_date,
'software_start_date' => $company->software_start_date,
'software_end_date' => $company->software_end_date,
'note' => $validated['note'] ?? __('Contract information updated'),
'creator_id' => auth()->id(),
]);
});
// 分支邏輯:若停用客戶,連帶停用其所有帳號 // 分支邏輯:若停用客戶,連帶停用其所有帳號
if ($validated['status'] == 0) { if ($validated['status'] == 0) {

View File

@@ -71,17 +71,21 @@ class MachineController extends AdminController
*/ */
public function logsAjax(Request $request, Machine $machine) public function logsAjax(Request $request, Machine $machine)
{ {
$per_page = $request->input('per_page', 10); $per_page = $request->input('per_page', 20);
$startDate = $request->get('start_date', now()->format('Y-m-d')); $startDate = $request->get('start_date');
$endDate = $request->get('end_date', now()->format('Y-m-d')); $endDate = $request->get('end_date');
$logs = $machine->logs() $logs = $machine->logs()
->when($request->level, function ($query, $level) { ->when($request->level, function ($query, $level) {
return $query->where('level', $level); return $query->where('level', $level);
}) })
->whereDate('created_at', '>=', $startDate) ->when($startDate, function ($query, $start) {
->whereDate('created_at', '<=', $endDate) return $query->where('created_at', '>=', str_replace('T', ' ', $start));
})
->when($endDate, function ($query, $end) {
return $query->where('created_at', '<=', str_replace('T', ' ', $end));
})
->when($request->type, function ($query, $type) { ->when($request->type, function ($query, $type) {
return $query->where('type', $type); return $query->where('type', $type);
}) })

View File

@@ -318,7 +318,11 @@ class PermissionController extends Controller
} }
if ($user->isSystemAdmin() && $request->filled('company_id')) { if ($user->isSystemAdmin() && $request->filled('company_id')) {
$query->where('company_id', $request->company_id); if ($request->company_id === 'system') {
$query->whereNull('company_id');
} else {
$query->where('company_id', $request->company_id);
}
} }
$per_page = $request->input('per_page', 10); $per_page = $request->input('per_page', 10);

View File

@@ -9,6 +9,7 @@ use App\Models\System\User;
use App\Models\Machine\Machine; use App\Models\Machine\Machine;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Jobs\Machine\ProcessStateLog;
class MachineAuthController extends Controller class MachineAuthController extends Controller
{ {
@@ -20,30 +21,14 @@ class MachineAuthController extends Controller
{ {
// 1. 驗證欄位 (相容舊版 Java App 發送的 JSON 格式) // 1. 驗證欄位 (相容舊版 Java App 發送的 JSON 格式)
$validated = $request->validate([ $validated = $request->validate([
'machine' => 'required|string', 'machine' => 'required|string',
'Su_Account' => 'required|string', 'Su_Account' => 'required|string',
'Su_Password' => 'required|string', 'Su_Password' => 'required|string',
'ip' => 'nullable|string', 'ip' => 'nullable|string',
'type' => 'nullable|string', 'type' => 'nullable|string',
]); ]);
// 2. 透過帳號尋找使用者 (允許使用 username 或 email) // 2. 取得機台物件 (需優先於帳密驗證,以便記錄日誌到正確機台)
$user = User::where('username', $validated['Su_Account'])
->orWhere('email', $validated['Su_Account'])
->first();
// 若無此帳號或密碼錯誤
if (!$user || !Hash::check($validated['Su_Password'], $user->password)) {
Log::warning("B000 機台登入失敗: 帳密錯誤", [
'account' => $validated['Su_Account'],
'machine' => $validated['machine']
]);
// 按現行設定Java 端只認 Success 字串,其餘視為帳密錯誤
return response()->json(['message' => 'Failed']);
}
// 3. 取得機台物件
// 因為此 API 無狀態 (沒有登入 session),為了避免被 global scope 擋住,直接取消所有 scope 來撈取
$machine = Machine::withoutGlobalScopes()->where('serial_no', $validated['machine'])->first(); $machine = Machine::withoutGlobalScopes()->where('serial_no', $validated['machine'])->first();
if (!$machine) { if (!$machine) {
@@ -53,40 +38,82 @@ class MachineAuthController extends Controller
return response()->json(['message' => 'Failed']); return response()->json(['message' => 'Failed']);
} }
// 4. RBAC 權限驗證 (遵循多租戶與機台授權規範) // 3. 透過帳號尋找使用者 (允許使用 username 或 email)
$user = User::where('username', $validated['Su_Account'])
->orWhere('email', $validated['Su_Account'])
->first();
// 4. 驗證密碼
if (!$user || !Hash::check($validated['Su_Password'], $user->password)) {
Log::warning("B000 機台登入失敗: 帳密錯誤", [
'account' => $validated['Su_Account'],
'machine' => $validated['machine']
]);
// 寫入機台日誌
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
__("Login failed: :account", ['account' => $validated['Su_Account']]),
'warning',
[],
'login'
);
return response()->json(['message' => 'Failed']);
}
// 5. RBAC 權限驗證 (遵循多租戶與機台授權規範)
$isAuthorized = false;
if ($user->isSystemAdmin()) { if ($user->isSystemAdmin()) {
// [系統管理員] : 擁有最高權限,可登入平台下轄所有機台,直接放行 $isAuthorized = true;
} elseif ($user->is_admin) { } elseif ($user->is_admin) {
// [公司管理員] : 不需要檢查 machine_user 表,但【必須驗證】該機台是否隸屬於他的公司 if ($machine->company_id === $user->company_id) {
if ($machine->company_id !== $user->company_id) { $isAuthorized = true;
Log::warning("B000 機台登入失敗: 企圖越權登入其他公司的機台", [
'user_id' => $user->id,
'user_company' => $user->company_id,
'machine_company' => $machine->company_id
]);
return response()->json(['message' => 'Forbidden']);
} }
} else { } else {
// [一般租戶帳號] : (包括補貨員等)必須嚴格檢查該帳號有沒有被分配到這台機器的 machine_user 關聯授權 if ($user->machines()->where('machine_id', $machine->id)->exists()) {
if (!$user->machines()->where('machine_id', $machine->id)->exists()) { $isAuthorized = true;
Log::warning("B000 機台登入失敗: 該帳號沒有此機台的授權", [
'user_id' => $user->id,
'machine_id' => $machine->id
]);
return response()->json(['message' => 'Forbidden']);
} }
} }
// 5. 驗證完美通過!回傳固定字串 Success 讓 Java 端放行 if (!$isAuthorized) {
Log::warning("B000 機台登入失敗: 權限不足", [
'user_id' => $user->id,
'machine_id' => $machine->id
]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
__("Unauthorized login attempt: :account", ['account' => $user->username]),
'warning',
[],
'login'
);
return response()->json(['message' => 'Forbidden']);
}
// 6. 驗證完美通過!
Log::info("B000 機台登入成功", [ Log::info("B000 機台登入成功", [
'account' => $user->username, 'account' => $user->username,
'machine' => $machine->serial_no 'machine' => $machine->serial_no
]); ]);
// 寫入成功登入日誌
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
__("User logged in: :name", ['name' => $user->name ?? $user->username]),
'info',
[],
'login'
);
return response()->json([ return response()->json([
'message' => 'Success' 'message' => 'Success',
'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken
]); ]);
} }
} }

View File

@@ -5,10 +5,15 @@ namespace App\Http\Controllers\Api\V1\App;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\Machine\Machine; use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Jobs\Machine\ProcessHeartbeat; use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessTimerStatus; use App\Jobs\Machine\ProcessTimerStatus;
use App\Jobs\Machine\ProcessCoinInventory; use App\Jobs\Machine\ProcessCoinInventory;
use App\Jobs\Machine\ProcessMachineError;
use App\Jobs\Machine\ProcessStateLog;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Cache;
class MachineController extends Controller class MachineController extends Controller
{ {
@@ -20,6 +25,69 @@ class MachineController extends Controller
$machine = $request->get('machine'); $machine = $request->get('machine');
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入的 Model 物件與認證 key $data = $request->except(['machine', 'key']); // 排除 Middleware 注入的 Model 物件與認證 key
// === 狀態異動觸發 (Redis 快取免查 DB) ===
$cacheKey = "machine:{$machine->serial_no}:state";
$oldState = Cache::get($cacheKey);
$currentPage = $data['current_page'] ?? null;
$doorStatus = $data['door_status'] ?? null;
$firmwareVersion = $data['firmware_version'] ?? null;
$model = $data['model'] ?? null;
if ($currentPage !== null || $doorStatus !== null || $firmwareVersion !== null || $model !== null) {
// 更新目前狀態到 Redis (保存 1 天)
$newState = $oldState ?? [];
if ($currentPage !== null) $newState['current_page'] = $currentPage;
if ($doorStatus !== null) $newState['door_status'] = $doorStatus;
if ($firmwareVersion !== null) $newState['firmware_version'] = $firmwareVersion;
if ($model !== null) $newState['model'] = $model;
Cache::put($cacheKey, $newState, 86400);
// 若有歷史紀錄才進行比對 (避開 Cache Miss 造成的雪崩)
if ($oldState !== null) {
// 1. 判斷頁面是否變更
if ($currentPage !== null && (string)$currentPage !== (string)($oldState['current_page'] ?? '')) {
// 只記錄「絕對狀態」,配合 lang 中 "Page X" 的翻譯
ProcessStateLog::dispatch($machine->id, $machine->company_id, "Page {$currentPage}", 'info');
}
// 2. 判斷門禁是否變更 (0: 關閉, 1: 開啟)
if ($doorStatus !== null && (string)$doorStatus !== (string)($oldState['door_status'] ?? '')) {
$doorMessage = $doorStatus == 1 ? "Door Opened" : "Door Closed";
$doorLevel = 'info'; // 不論開關門皆為 info避免觸發異常狀態
ProcessStateLog::dispatch($machine->id, $machine->company_id, $doorMessage, $doorLevel);
}
// 3. 判斷韌體版本是否變更
if ($firmwareVersion !== null && (string)$firmwareVersion !== (string)($oldState['firmware_version'] ?? '')) {
$oldVersion = $oldState['firmware_version'] ?? 'Unknown';
// 直接在 Controller 進行翻譯並填值,確保儲存到 DB 的是最終正確字串
$versionMessage = __("Firmware updated to :version", ['version' => $firmwareVersion]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$versionMessage,
'info',
['old' => $oldVersion, 'new' => $firmwareVersion]
);
}
// 4. 判斷型號是否變更
if ($model !== null && (string)$model !== (string)($oldState['model'] ?? '')) {
$oldModel = $oldState['model'] ?? 'Unknown';
$modelMessage = __("Model changed to :model", ['model' => $model]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$modelMessage,
'info',
['old' => $oldModel, 'new' => $model]
);
}
}
}
// 異步處理狀態更新 // 異步處理狀態更新
ProcessHeartbeat::dispatch($machine->serial_no, $data); ProcessHeartbeat::dispatch($machine->serial_no, $data);
@@ -104,7 +172,7 @@ class MachineController extends Controller
{ {
$machine = $request->get('machine'); $machine = $request->get('machine');
$slots = $machine->slots()->with('product')->get(); $slots = $machine->slots()->with('product')->get();
// 自動轉 Success: 若機台來撈 B017代表之前的 reload_stock 指令已成功被機台響應 // 自動轉 Success: 若機台來撈 B017代表之前的 reload_stock 指令已成功被機台響應
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id) \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'reload_stock') ->where('command_type', 'reload_stock')
@@ -194,4 +262,280 @@ class MachineController extends Controller
] ]
]); ]);
} }
/**
* B005: Download Machine Advertisements (Synchronous)
*/
public function getAdvertisements(Request $request)
{
$machine = $request->get('machine');
$advertisements = \App\Models\Machine\MachineAdvertisement::where('machine_id', $machine->id)
->with([
'advertisement' => function ($query) {
$query->playing();
}
])
->get()
->filter(fn($ma) => $ma->advertisement !== null)
->map(function ($ma) {
// 定義顯示順序權重 (待機 > 購物 > 成功禮)
$posWeight = [
'standby' => 1,
'vending' => 2,
'visit_gift' => 3
];
// 為了相容現有機台 App 邏輯:
// App 讀取 t070v03 作為位置標籤 (flag)
// 1. HomeActivity (待機) 讀取 "3"
// 2. FontendActivity (販賣頁) 讀取 "1"
$posIdMap = [
'standby' => '3',
'vending' => '1',
'visit_gift' => '2'
];
return [
't070v01' => $ma->advertisement->name,
't070v02' => (string) ($ma->advertisement->duration ?? 15), // 秒數改放這裡
't070v03' => (string) ($posIdMap[$ma->position] ?? '1'), // 位置數字改放這裡 (App 會讀這欄當 Flag)
't070v04' => $ma->advertisement->url ? (str_starts_with($ma->advertisement->url, 'http') ? $ma->advertisement->url : asset($ma->advertisement->url)) : '',
't070v05' => (string) $ma->sort_order,
'raw_pos_weight' => $posWeight[$ma->position] ?? 99,
'raw_sort' => (int) $ma->sort_order
];
})
->sortBy([
['raw_pos_weight', 'asc'],
['raw_sort', 'asc']
])
->values()
->map(function ($item) {
unset($item['raw_pos_weight'], $item['raw_sort']);
return $item;
});
return response()->json([
'success' => true,
'code' => 200,
'data' => $advertisements->values()
]);
}
/**
* B009: Report Machine Slot List / Supplementary (Synchronous)
*/
public function reportSlotList(Request $request, \App\Services\Machine\MachineService $machineService)
{
$machine = $request->get('machine');
$payload = $request->all();
$account = $payload['account'] ?? null;
// 1. 驗證帳號是否存在 (驗證執行補貨的人員身分)
$user = User::where('username', $account)
->orWhere('email', $account)
->first();
if (!$user) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Unauthorized: Account not found',
'status' => ''
], 403);
}
// 2. 階層式權限驗證 (遵循 RBAC 多租戶規範)
$isAuthorized = false;
if ($user->isSystemAdmin()) {
// [系統層]:系統管理員可異動所有機台
$isAuthorized = true;
} elseif ($user->is_admin) {
// [公司層]:公司管理員需驗證此機台是否隸屬於該公司
$isAuthorized = ($machine->company_id === $user->company_id);
} else {
// [人員層]:一般人員需檢查 machine_user 授權表
$isAuthorized = $user->machines()->where('machine_id', $machine->id)->exists();
}
if (!$isAuthorized) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Unauthorized: Account not authorized for this machine',
'status' => ''
], 403);
}
// 3. 映射舊版機台回傳格式 (Map legacy machine format)
// 支持單個物件 data: {} 或 陣列 data: [{}] (Handle both single object and array)
$legacyData = $payload['data'] ?? [];
if (Arr::isAssoc($legacyData)) {
$legacyData = [$legacyData];
}
$mappedSlots = array_map(function ($item) {
return [
'slot_no' => $item['tid'] ?? null,
'product_id' => $item['t060v00'] ?? null,
'stock' => $item['num'] ?? 0,
'type' => $item['type'] ?? null,
];
}, $legacyData);
// 過濾無效資料 (Filter invalid entries)
$mappedSlots = array_filter($mappedSlots, fn($s) => $s['slot_no'] !== null);
// 同步處理更新庫存 (直接更新不進隊列)
$machineService->syncSlots($machine, $mappedSlots);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Slot report synchronized success',
'status' => '49'
]);
}
/**
* B012_new: Download Product Catalog (Synchronous)
*/
public function getProducts(Request $request)
{
$machine = $request->get('machine');
$products = \App\Models\Product\Product::where('company_id', $machine->company_id)
->with(['translations'])
->active()
->get()
->map(function ($product) {
// 提取多語系名稱 (Extract translations)
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
return [
't060v00' => (string) $product->id, // ID 仍建議維持字串,增加未來編號彈性
't060v01' => $product->name,
't060v01_en' => $nameEn,
't060v01_jp' => $nameJp,
't060v03' => $product->spec ?? '',
't060v06' => $product->image_url ? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url)) : '',
't060v09' => (float) $product->price,
't060v11' => (int) ($product->track_limit ?? 10),
't060v30' => (float) ($product->member_price ?? $product->price),
't060v40' => $product->metadata['marketing_plan'] ?? '', // 行銷計畫
't060v41' => $product->metadata['material_code'] ?? $product->barcode ?? '', // 物料編碼 (優先從 metadata 找,回退至條碼)
'spring_limit' => (int) ($product->spring_limit ?? 10),
'track_limit' => (int) ($product->track_limit ?? 10),
't063v03' => (float) $product->price,
];
});
return response()->json([
'success' => true,
'code' => 200,
'data' => $products
]);
}
/**
* B013: Report Machine Hardware Error/Status (Asynchronous)
*/
public function reportError(Request $request)
{
$machine = $request->get('machine');
$data = $request->only(['tid', 'error_code']);
// 異步分派處理 (Dispatch to queue)
ProcessMachineError::dispatch($machine->serial_no, $data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Error report accepted',
], 202); // 202 Accepted
}
/**
* B014: Download Machine Settings & Config (Synchronous, Requires User Auth)
* 用於機台引導階段,同步金流、發票與機台專屬 API Token。
*/
public function getSettings(Request $request)
{
$serialNo = $request->input('machine');
$user = $request->user();
// 1. 查找機台 (忽略全局範圍以進行認領)
$machine = Machine::withoutGlobalScopes()
->with(['paymentConfig', 'company'])
->where('serial_no', $serialNo)
->first();
if (!$machine) {
return response()->json([
'success' => false,
'code' => 404,
'message' => 'Machine not found'
], 404);
}
// 2. 權限加強驗證 (RBAC)
$isAuthorized = false;
if ($user->isSystemAdmin()) {
$isAuthorized = true;
} elseif ($machine->company_id === $user->company_id) {
// 公司管理員或已授權員工才能存取
if ($user->is_admin || $user->machines()->where('machine_id', $machine->id)->exists()) {
$isAuthorized = true;
}
}
if (!$isAuthorized) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Forbidden: You do not have permission to configure this machine'
], 403);
}
// 3. 獲取關聯設定
$paymentSettings = $machine->paymentConfig->settings ?? [];
$companySettings = $machine->company->settings ?? [];
// 4. 映射 App 預期欄位 (嚴格遵守 HttpAPI.java 結構)
$data = [
't050v01' => $machine->serial_no,
'api_token' => $machine->api_token, // 向 App 核發正式通訊 Token
// 玉山支付
't050v41' => $paymentSettings['esun_store_id'] ?? '',
't050v42' => $paymentSettings['esun_term_id'] ?? '',
't050v43' => $paymentSettings['esun_hash'] ?? '',
// 電子發票 (綠界)
't050v34' => $companySettings['invoice_merchant_id'] ?? '',
't050v35' => $companySettings['invoice_hash_key'] ?? '',
't050v36' => $companySettings['invoice_hash_iv'] ?? '',
't050v38' => $companySettings['invoice_email'] ?? '',
// 趨勢支付 (TrendPay/Greenpay)
'TP_APP_ID' => $paymentSettings['tp_app_id'] ?? '',
'TP_APP_KEY' => $paymentSettings['tp_app_key'] ?? '',
'TP_PARTNER_KEY' => $paymentSettings['tp_partner_key'] ?? '',
// 各類行動支付特店 ID
'TP_LINE_MERCHANT_ID' => $paymentSettings['tp_line_merchant_id'] ?? '',
'TP_PS_MERCHANT_ID' => $paymentSettings['tp_ps_merchant_id'] ?? '',
'TP_EASY_MERCHANT_ID' => $paymentSettings['tp_easy_merchant_id'] ?? '',
'TP_PI_MERCHANT_ID' => $paymentSettings['tp_pi_merchant_id'] ?? '',
'TP_JKO_MERCHANT_ID' => $paymentSettings['tp_jko_merchant_id'] ?? '',
];
return response()->json([
'success' => true,
'code' => 200,
'data' => [$data] // App 預期的是包含單一物件的陣列
]);
}
} }

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use App\Services\Machine\MachineService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessMachineError implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $serialNo;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(string $serialNo, array $data)
{
$this->serialNo = $serialNo;
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(MachineService $service): void
{
$machine = Machine::where('serial_no', $this->serialNo)->first();
if ($machine) {
$service->recordErrorLog($machine, $this->data);
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\MachineLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessStateLog implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $machineId;
protected $companyId;
protected $message;
protected $level;
protected $type;
protected $context;
/**
* Create a new job instance.
*/
public function __construct(int $machineId, ?int $companyId, string $message, string $level = 'info', array $context = [], string $type = 'status')
{
$this->machineId = $machineId;
$this->companyId = $companyId;
$this->message = $message;
$this->level = $level;
$this->context = $context;
$this->type = $type;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
MachineLog::create([
'machine_id' => $this->machineId,
'company_id' => $this->companyId,
'type' => $this->type,
'level' => $this->level,
'message' => $this->message,
'context' => $this->context,
]);
} catch (\Exception $e) {
Log::error("Failed to create state log for machine {$this->machineId}: " . $e->getMessage());
throw $e;
}
}
}

View File

@@ -24,6 +24,33 @@ class MachineLog extends Model
'context' => 'array', 'context' => 'array',
]; ];
protected $appends = [
'translated_message',
];
/**
* 動態重組翻譯後的訊息
*/
public function getTranslatedMessageAttribute(): string
{
$context = $this->context;
// 若 context 中已有翻譯標籤 (B013 封裝),則進行動態重組
if (isset($context['translated_label'])) {
$label = __($context['translated_label']);
$tid = $context['tid'] ?? null;
$code = $context['raw_code'] ?? '0000';
if ($tid) {
return __('Slot') . " {$tid}: {$label} (Code: {$code})";
}
return "{$label} (Code: {$code})";
}
// 預設退回原始 message (支援歷史資料的翻譯判定與佔位符替換)
return __($this->message, $context ?? []);
}
public function machine() public function machine()
{ {
return $this->belongsTo(Machine::class); return $this->belongsTo(Machine::class);

View File

@@ -14,6 +14,7 @@ class MachineSlot extends Model
'machine_id', 'machine_id',
'product_id', 'product_id',
'slot_no', 'slot_no',
'type',
'max_stock', 'max_stock',
'stock', 'stock',
'expiry_date', 'expiry_date',

View File

@@ -10,13 +10,20 @@ use App\Traits\TenantScoped;
class Product extends Model class Product extends Model
{ {
use HasFactory, SoftDeletes, TenantScoped; use HasFactory, SoftDeletes, TenantScoped;
/**
* Scope a query to only include active products.
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
protected $fillable = [ protected $fillable = [
'company_id', 'company_id',
'category_id', 'category_id',
'name', 'name',
'name_dictionary_key', 'name_dictionary_key',
'sku',
'barcode', 'barcode',
'spec', 'spec',
'manufacturer', 'manufacturer',

View File

@@ -18,11 +18,15 @@ class Advertisement extends Model
'duration', 'duration',
'url', 'url',
'is_active', 'is_active',
'start_at',
'end_at',
]; ];
protected $casts = [ protected $casts = [
'duration' => 'integer', 'duration' => 'integer',
'is_active' => 'boolean', 'is_active' => 'boolean',
'start_at' => 'datetime',
'end_at' => 'datetime',
]; ];
/** /**
@@ -48,4 +52,21 @@ class Advertisement extends Model
{ {
return $query->where('is_active', true); return $query->where('is_active', true);
} }
/**
* Scope a query to only include advertisements that should be playing now.
*/
public function scopePlaying($query)
{
$now = now();
return $query->where('is_active', true)
->where(function ($q) use ($now) {
$q->whereNull('start_at')
->orWhere('start_at', '<=', $now);
})
->where(function ($q) use ($now) {
$q->whereNull('end_at')
->orWhere('end_at', '>=', $now);
});
}
} }

View File

@@ -25,17 +25,33 @@ class Company extends Model
'status', 'status',
'start_date', 'start_date',
'end_date', 'end_date',
'warranty_start_date',
'warranty_end_date',
'software_start_date',
'software_end_date',
'note', 'note',
'settings', 'settings',
]; ];
protected $casts = [ protected $casts = [
'start_date' => 'date', 'start_date' => 'date:Y-m-d',
'end_date' => 'date', 'end_date' => 'date:Y-m-d',
'warranty_start_date' => 'date:Y-m-d',
'warranty_end_date' => 'date:Y-m-d',
'software_start_date' => 'date:Y-m-d',
'software_end_date' => 'date:Y-m-d',
'status' => 'integer', 'status' => 'integer',
'settings' => 'array', 'settings' => 'array',
]; ];
/**
* Get the contract history for the company.
*/
public function contracts(): HasMany
{
return $this->hasMany(CompanyContract::class)->latest();
}
/** /**
* Get the users for the company. * Get the users for the company.
*/ */

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models\System;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CompanyContract extends Model
{
use HasFactory;
protected $fillable = [
'company_id',
'type',
'start_date',
'end_date',
'warranty_start_date',
'warranty_end_date',
'software_start_date',
'software_end_date',
'note',
'creator_id',
];
protected $casts = [
'start_date' => 'date:Y-m-d',
'end_date' => 'date:Y-m-d',
'warranty_start_date' => 'date:Y-m-d',
'warranty_end_date' => 'date:Y-m-d',
'software_start_date' => 'date:Y-m-d',
'software_end_date' => 'date:Y-m-d',
];
/**
* Get the company that owns the contract.
*/
public function company(): BelongsTo
{
return $this->belongsTo(Company::class);
}
/**
* Get the user who created the record.
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'creator_id');
}
}

View File

@@ -14,7 +14,7 @@ class OrderItem extends Model
'order_id', 'order_id',
'product_id', 'product_id',
'product_name', 'product_name',
'sku', 'barcode',
'price', 'price',
'quantity', 'quantity',
'subtotal', 'subtotal',

View File

@@ -10,6 +10,56 @@ use Carbon\Carbon;
class MachineService class MachineService
{ {
/**
* B013: 硬體狀態代碼對照表 (Hardware Status Code Mapping)
*/
public const ERROR_CODE_MAP = [
// 出貨狀態類 (Prefix: 04 - BUY_STATUS)
'0401' => ['label' => 'Dispensing in progress', 'level' => 'info'],
'0402' => ['label' => 'Dispense successful', 'level' => 'info'],
'0403' => ['label' => 'Slot jammed', 'level' => 'error'],
'0404' => ['label' => 'Motor not stopped', 'level' => 'warning'],
'0406' => ['label' => 'Slot not found', 'level' => 'error'],
'0407' => ['label' => 'Dispense error (0407)', 'level' => 'error'],
'0408' => ['label' => 'Dispense error (0408)', 'level' => 'error'],
'0409' => ['label' => 'Dispense error (0409)', 'level' => 'error'],
'040A' => ['label' => 'Dispense error (040A)', 'level' => 'error'],
'0410' => ['label' => 'Elevator rising', 'level' => 'info'],
'0411' => ['label' => 'Elevator descending', 'level' => 'info'],
'0412' => ['label' => 'Elevator rise error', 'level' => 'error'],
'0413' => ['label' => 'Elevator descent error', 'level' => 'error'],
'0414' => ['label' => 'Pickup door closed', 'level' => 'info'],
'0415' => ['label' => 'Pickup door error', 'level' => 'error'],
'0416' => ['label' => 'Delivery door opened', 'level' => 'info'],
'0417' => ['label' => 'Delivery door open error', 'level' => 'error'],
'0418' => ['label' => 'Delivering product', 'level' => 'info'],
'0419' => ['label' => 'Delivery door closed', 'level' => 'info'],
'0420' => ['label' => 'Delivery door close error', 'level' => 'error'],
'0421' => ['label' => 'Hopper empty', 'level' => 'warning'],
'0422' => ['label' => 'Hopper overheated', 'level' => 'warning'],
'0423' => ['label' => 'Hopper heating timeout', 'level' => 'error'],
'0424' => ['label' => 'Hopper error (0424)', 'level' => 'error'],
'0426' => ['label' => 'Microwave door opened', 'level' => 'info'],
'0427' => ['label' => 'Microwave door error', 'level' => 'error'],
'04FF' => ['label' => 'Dispense stopped', 'level' => 'info'],
// 貨道狀態類 (Prefix: 02 - SLOT_STATUS)
'0201' => ['label' => 'Slot normal', 'level' => 'info'],
'0202' => ['label' => 'Product empty', 'level' => 'warning'],
'0203' => ['label' => 'Slot empty', 'level' => 'warning'],
'0206' => ['label' => 'Slot not closed', 'level' => 'warning'],
'0207' => ['label' => 'Slot motor error (0207)', 'level' => 'error'],
'0208' => ['label' => 'Slot motor error (0208)', 'level' => 'error'],
'0209' => ['label' => 'Slot motor error (0209)', 'level' => 'error'],
'0212' => ['label' => 'Hopper empty (0212)', 'level' => 'warning'],
// 機台整體狀態類 (Prefix: 54 - MACHINE_STATUS)
'5400' => ['label' => 'Machine normal', 'level' => 'info'],
'5401' => ['label' => 'Elevator sensor error', 'level' => 'error'],
'5402' => ['label' => 'Pickup door not closed', 'level' => 'warning'],
'5403' => ['label' => 'Elevator failure', 'level' => 'error'],
];
/** /**
* Update machine heartbeat and status. * Update machine heartbeat and status.
* *
@@ -64,24 +114,50 @@ class MachineService
public function syncSlots(Machine $machine, array $slotsData): void public function syncSlots(Machine $machine, array $slotsData): void
{ {
DB::transaction(function () use ($machine, $slotsData) { DB::transaction(function () use ($machine, $slotsData) {
// 蒐集所有傳入的商品 ID (可能是 SKU 或 實際 ID)
$productCodes = collect($slotsData)->pluck('product_id')->filter()->unique()->toArray();
// 優先以 ID 查詢商品,若 ID 不存在則嘗試 Barcode (Prioritize ID lookup, fallback to Barcode)
$products = \App\Models\Product\Product::whereIn('id', $productCodes)
->orWhereIn('barcode', $productCodes)
->get();
foreach ($slotsData as $slotData) { foreach ($slotsData as $slotData) {
$slotNo = $slotData['slot_no'] ?? null; $slotNo = $slotData['slot_no'] ?? null;
if (!$slotNo) continue; if (!$slotNo) continue;
$existingSlot = $machine->slots()->where('slot_no', $slotNo)->first(); $existingSlot = $machine->slots()->where('slot_no', $slotNo)->first();
// 查找對應的實體 ID (支援 ID 與 Barcode 比對)
$productCode = $slotData['product_id'] ?? null;
$actualProductId = null;
if ($productCode) {
$actualProductId = $products->first(function ($p) use ($productCode) {
return (string)$p->id === (string)$productCode || $p->barcode === (string)$productCode;
})?->id;
}
// 根據貨道類型自動決定上限 (Auto-calculate max_stock based on slot type)
// 若未提供 type預設為 '1' (履帶/Track)
$slotType = $slotData['type'] ?? $existingSlot->type ?? '1';
if ($actualProductId) {
$product = $products->find($actualProductId);
if ($product) {
// 1: 履帶, 2: 彈簧
$calculatedMaxStock = ($slotType == '1') ? $product->track_limit : $product->spring_limit;
$slotData['capacity'] = $calculatedMaxStock ?? $slotData['capacity'] ?? null;
}
}
$updateData = [ $updateData = [
'product_id' => $slotData['product_id'] ?? null, 'product_id' => $actualProductId,
'type' => $slotType,
'stock' => $slotData['stock'] ?? 0, 'stock' => $slotData['stock'] ?? 0,
'capacity' => $slotData['capacity'] ?? ($existingSlot->capacity ?? 10), 'max_stock' => $slotData['capacity'] ?? ($existingSlot->max_stock ?? 10),
'price' => $slotData['price'] ?? ($existingSlot->price ?? 0), 'is_active' => true,
'last_restocked_at' => now(),
]; ];
// 如果商品變了,或者這是一次明確的補貨回報,清空效期等待管理員更新 // 如果這是一次明確的補貨回報,建議更新時間並記錄
// 這裡我們暫定只要有 report 進來,就需要重新確認效期
$updateData['expiry_date'] = null;
if ($existingSlot) { if ($existingSlot) {
$existingSlot->update($updateData); $existingSlot->update($updateData);
} else { } else {
@@ -152,6 +228,36 @@ class MachineService
}); });
} }
/**
* B013: Record machine hardware error/status log with auto-translation.
*
* @param Machine $machine
* @param array $data
* @return MachineLog
*/
public function recordErrorLog(Machine $machine, array $data): MachineLog
{
$errorCode = $data['error_code'] ?? '0000';
$mapping = self::ERROR_CODE_MAP[$errorCode] ?? ['label' => 'Unknown Status', 'level' => 'error'];
$slotNo = $data['tid'] ?? null;
$label = $mapping['label'];
// 儲存原始英文格式作為 DB 備用,前端顯示會優先使用 model accessor 的動態翻譯內容
$message = $slotNo ? "Slot {$slotNo}: {$label} (Code: {$errorCode})" : "{$label} (Code: {$errorCode})";
return $machine->logs()->create([
'company_id' => $machine->company_id,
'type' => 'submachine',
'level' => $mapping['level'],
'message' => $message,
'context' => array_merge($data, [
'translated_label' => $label,
'raw_code' => $errorCode
]),
]);
}
/** /**
* Update machine slot stock (single slot). * Update machine slot stock (single slot).
* Legacy support for recordLog (Existing code). * Legacy support for recordLog (Existing code).

View File

@@ -42,7 +42,7 @@ class TransactionService
$order->items()->create([ $order->items()->create([
'product_id' => $item['product_id'], 'product_id' => $item['product_id'],
'product_name' => $item['product_name'] ?? 'Unknown', 'product_name' => $item['product_name'] ?? 'Unknown',
'sku' => $item['sku'] ?? null, 'barcode' => $item['barcode'] ?? null,
'price' => $item['price'], 'price' => $item['price'],
'quantity' => $item['quantity'], 'quantity' => $item['quantity'],
'subtotal' => $item['price'] * $item['quantity'], 'subtotal' => $item['price'] * $item['quantity'],

View File

@@ -8,6 +8,228 @@ return [
[ [
'name' => '機台核心通訊 (IoT Core)', 'name' => '機台核心通訊 (IoT Core)',
'apis' => [ 'apis' => [
[
'name' => 'B000: 維運人員登入認證 (Technician Login)',
'slug' => 'b000-tech-login',
'method' => 'POST',
'path' => '/api/v1/app/admin/login/B000',
'description' => '機台啟動引導的第一步。維運人員輸入個人帳密與機台編號進行認證,成功後核發臨時 Sanctum Token 供後續 B014 下載敏感設定使用。',
'headers' => [
'Content-Type' => 'application/json',
],
'parameters' => [
'username' => [
'type' => 'string',
'required' => true,
'description' => '維運人員帳號',
'example' => 'admin_test'
],
'password' => [
'type' => 'string',
'required' => true,
'description' => '維運人員密碼',
'example' => 'password123'
],
'machine' => [
'type' => 'string',
'required' => true,
'description' => '機台序號 (Serial No)',
'example' => 'SN202604130001'
],
],
'response_parameters' => [
'message' => [
'type' => 'string',
'description' => '回應訊息',
'example' => 'Success'
],
'token' => [
'type' => 'string',
'description' => '臨時身份認證 Token (Sanctum)',
'example' => '1|abcdefg...'
],
],
'request' => [
'username' => 'admin_test',
'password' => 'password123',
'machine' => 'SN202604130001'
],
'response' => [
'message' => 'Success',
'token' => '1|abcdefg...'
],
],
[
'name' => 'B014: 機台參數與金鑰下載 (Config Download)',
'slug' => 'b014-config-download',
'method' => 'POST',
'path' => '/api/v1/app/machine/setting/B014',
'description' => '機台引導階段的第二步。在人員登入後,透過此介面下載金流金鑰、電子發票設定與機台專屬通訊 Token。',
'headers' => [
'Authorization' => 'Bearer <user_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'machine' => [
'type' => 'string',
'required' => true,
'description' => '機台序號',
'example' => 'SN202604130001'
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '是否成功',
'example' => true
],
'data' => [
'type' => 'array',
'description' => '配置物件陣列。包含t050v01 (序號), api_token (通訊 Token), t050v41~43 (玉山設定), t050v34~38 (發票設定), TP_... (趨勢/手機支付設定)',
'example' => [
[
't050v01' => 'SN202604130001',
'api_token' => 'mac_token_...',
't050v41' => '80812345',
't050v34' => '2000132',
'TP_APP_ID' => 'GP_001'
]
]
],
],
'request' => [
'machine' => 'SN202604130001'
],
'response' => [
'success' => true,
'code' => 200,
'data' => [
[
't050v01' => 'SN202604130001',
'api_token' => 'mac_token_...',
't050v41' => '80812345',
't050v42' => '9001',
't050v43' => 'hash_key',
't050v34' => '2000132',
'TP_APP_ID' => 'GP_001'
]
]
],
'notes' => '此 API 受 auth:sanctum 保護,必須在 Header 帶上從 B000 取得的 Token。'
],
[
'name' => 'B005: 廣告清單同步 (Ad Sync)',
'slug' => 'b005-ad-sync',
'method' => 'GET',
'path' => '/api/v1/app/machine/ad/B005',
'description' => '用於機台端獲取目前應播放的廣告檔案 URL 清單。此介面無需 Request Body。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
'data' => [
'type' => 'array',
'description' => '廣告物件陣列。內部欄位包含t070v01 (名稱), t070v02 (秒數), t070v03 (位置1:販賣頁, 2:來店禮, 3:待機廣告), t070v04 (URL), t070v05 (順位)',
'example' => [
[
't070v01' => '測試機台廣告',
't070v02' => 15,
't070v03' => 3,
't070v04' => 'https://example.com/ad1.mp4',
't070v05' => 1
]
]
],
],
'request' => [],
'response' => [
'success' => true,
'code' => 200,
'message' => 'OK',
'data' => [
[
't070v01' => '測試機台廣告',
't070v02' => 15,
't070v03' => 3,
't070v04' => 'https://example.com/ad1.mp4',
't070v05' => 1
]
]
],
],
[
'name' => 'B009: 貨道庫存即時回報 (Inventory Report)',
'slug' => 'b009-inventory-report',
'method' => 'PUT',
'path' => '/api/v1/app/products/supplementary/B009',
'description' => '當人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。需進行 RBAC 權限核查。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'account' => [
'type' => 'string',
'required' => true,
'description' => '操作人員帳號',
'example' => '0999123456'
],
'data' => [
'type' => 'array',
'required' => true,
'description' => '貨道數據陣列。tid: 貨道號, t060v00: 商品 ID, num: 庫存量',
'example' => [
['tid' => '1', 't060v00' => '1', 'num' => '10']
]
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '同步是否成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
'message' => [
'type' => 'string',
'description' => '回應訊息',
'example' => 'Slot report synchronized success'
],
'status' => [
'type' => 'string',
'description' => '固定回傳 49 代表同步完成',
'example' => '49'
],
],
'request' => [
'account' => '0999123456',
'data' => [
['tid' => '1', 't060v00' => '1', 'num' => '10']
]
],
'response' => [
'success' => true,
'code' => 200,
'message' => 'Slot report synchronized success',
'status' => '49'
],
],
[ [
'name' => 'B010: 心跳上報與狀態同步 (Heartbeat)', 'name' => 'B010: 心跳上報與狀態同步 (Heartbeat)',
'slug' => 'b010-heartbeat', 'slug' => 'b010-heartbeat',
@@ -118,6 +340,125 @@ return [
'status' => '49' 'status' => '49'
], ],
'notes' => '機台收到 B010 回應中的特定 `status` 代碼後,應根據對照表執行對應的指令動作或 API 呼叫 (如 B017)。若為空則代表無指令。' 'notes' => '機台收到 B010 回應中的特定 `status` 代碼後,應根據對照表執行對應的指令動作或 API 呼叫 (如 B017)。若為空則代表無指令。'
],
[
'name' => 'B012: 商品配置與商品主檔同步 (Unified Sync)',
'slug' => 'b012-unified-sync',
'method' => 'GET/PATCH',
'path' => '/api/v1/app/machine/products/B012',
'description' => '用於機台端獲取目前所有可販售商品的詳細配置。GET 為全量同步PATCH 為增量更新。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否處理成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
'data' => [
'type' => 'array',
'description' => '商品明細物件陣列',
'example' => [
[
't060v00' => '1',
't060v01' => '可口可樂 330ml',
't060v01_en' => 'Coca Cola',
't060v01_jp' => 'コカコーラ',
't060v03' => 'Cold Drink',
't060v06' => 'https://.../coke.png',
't060v09' => 25.0,
't060v11' => 10,
't060v30' => 20.0,
't063v03' => 25.0,
't060v40' => 'Buy 1 Get 1',
't060v41' => 'SKU-001',
'spring_limit' => 10,
'track_limit' => 15
]
]
]
],
'request' => [],
'response' => [
'success' => true,
'code' => 200,
'message' => 'OK',
'data' => [
[
't060v00' => '1',
't060v01' => '可口可樂 330ml',
't060v01_en' => 'Coca Cola',
't060v01_jp' => 'コカコーラ',
't060v03' => 'Cold Drink',
't060v06' => 'https://.../coke.png',
't060v09' => 25.0,
't060v11' => 10,
't060v30' => 20.0,
't063v03' => 25.0,
't060v40' => 'Buy 1 Get 1',
't060v41' => 'SKU-001',
'spring_limit' => 10,
'track_limit' => 15
]
]
],
'notes' => '運作邏輯 (Client-side Logic): GET 執行全量同步App 應於收到成功回應後,先執行 deleteAll() 再進行 insertAll()。PATCH 執行增量更新App 僅對記憶體中的既存商品進行欄位值覆蓋 (Patching)。'
],
[
'name' => 'B013: 機台故障與異常狀態上報 (Error/Status Report)',
'slug' => 'b013-error-report',
'method' => 'POST',
'path' => '/api/v1/app/machine/error/B013',
'description' => '用於接收機台發出的即時硬體狀態代碼(如卡貨、門未關)。身份由 Bearer Token 識別,回傳成功代表伺服器已將任務列入異步隊列處理。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'tid' => [
'type' => 'integer',
'required' => false,
'description' => '涉及到之具體貨道編號 (Slot No)',
'example' => 12
],
'error_code' => [
'type' => 'string',
'required' => true,
'description' => '硬體狀態代碼 (4 位 16 進位字串)',
'example' => '0403'
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否已成功接收',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
],
'request' => [
'tid' => 12,
'error_code' => '0403',
],
'response' => [
'success' => true,
'code' => 200,
'message' => 'Error report accepted'
],
'notes' => '硬體代碼對照表見後端 MachineService::ERROR_CODE_MAP 定義。
0402: 出貨成功, 0403: 貨道卡貨, 0202: 貨道缺貨, 0415: 取貨門異常...等。'
] ]
] ]
] ]

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('machine_slots', function (Blueprint $group) {
$group->string('type')->nullable()->after('slot_no')->comment('1: spring, 2: track');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machine_slots', function (Blueprint $group) {
$group->dropColumn('type');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('machine_slots', function (Blueprint $group) {
$group->string('type')->nullable()->comment('1: track, 2: spring')->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machine_slots', function (Blueprint $group) {
$group->string('type')->nullable()->comment('1: spring, 2: track')->change();
});
}
};

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. 從 products 表移除 sku
Schema::table('products', function (Blueprint $table) {
// 因為公司外鍵正在使用這個複合索引,必須先補一個獨立索引給公司
$table->index('company_id');
// 現在可以安全移除含有 sku 的索引了
$table->dropIndex(['company_id', 'sku']);
$table->dropColumn('sku');
});
// 2. 從 order_items 表移除 sku 並新增 barcode
Schema::table('order_items', function (Blueprint $table) {
$table->dropColumn('sku');
$table->string('barcode')->nullable()->after('product_name')->comment('商品條碼 (備份)');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('order_items', function (Blueprint $table) {
$table->dropColumn('barcode');
$table->string('sku')->nullable()->after('product_name')->comment('商品編號 (備份)');
});
Schema::table('products', function (Blueprint $table) {
$table->string('sku')->nullable()->after('name_dictionary_key')->comment('商品編號');
$table->index(['company_id', 'sku']);
});
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->date('warranty_start_date')->nullable()->after('end_date')->comment('保固起始日');
$table->date('warranty_end_date')->nullable()->after('warranty_start_date')->comment('保固結束日');
$table->date('software_start_date')->nullable()->after('warranty_end_date')->comment('軟體服務起始日');
$table->date('software_end_date')->nullable()->after('software_start_date')->comment('軟體服務結束日');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->dropColumn([
'warranty_start_date',
'warranty_end_date',
'software_start_date',
'software_end_date'
]);
});
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('company_contracts', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained()->onDelete('cascade');
$table->string('type')->comment('buyout, lease');
$table->date('start_date')->nullable();
$table->date('end_date')->nullable();
$table->date('warranty_start_date')->nullable();
$table->date('warranty_end_date')->nullable();
$table->date('software_start_date')->nullable();
$table->date('software_end_date')->nullable();
$table->text('note')->nullable();
$table->foreignId('creator_id')->nullable()->constrained('users');
$table->timestamps();
$table->index(['company_id', 'created_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('company_contracts');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('advertisements', function (Blueprint $table) {
$table->dateTime('start_at')->nullable()->after('url')->comment('發布時間');
$table->dateTime('end_at')->nullable()->after('start_at')->comment('下架時間');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('advertisements', function (Blueprint $table) {
$table->dropColumn(['start_at', 'end_at']);
});
}
};

View File

@@ -4,6 +4,67 @@
--- ---
## 🔐 B000: 維運人員登入認證 (Technician Login)
機台引導階段 (Provisioning) 的第一步,用於核發臨時身份 Token 以便後續下載敏感設定。
### 1. API 資訊
- **Endpoint**: `POST /api/v1/app/admin/login/B000`
- **認證方式**: 無 (需傳入 `username`, `password`, `machine`)
- **回應內容**: `token` (Sanctum Token)
### 2. 回應範例
```json
{
"message": "Success",
"token": "3|abcdef1234567890..."
}
```
---
## 🔑 B014: 機台參數與金鑰下載 (Config Download)
下載機台運作所需的支付金鑰、電子發票設定與正式通訊 Token。
### 1. API 資訊
- **Endpoint**: `POST /api/v1/app/machine/setting/B014`
- **認證方式**: **Bearer Token** (需帶上 B000 取得的 Token)
- **Header**: `Authorization: Bearer {token}`
### 2. 請求參數
- `machine`: 機台序號 (Serial No)
### 3. 回應規格 (欄位映射)
| 欄位 | 說明 | 來源範例 |
| :--- | :--- | :--- |
| `t050v01` | 機台序號 | `SN2026041301` |
| `api_token` | **機台正式 Token** | 後續 B010/B600 認證用 |
| `t050v41` | 玉山特店編號 | `ESUN_STORE_ID` |
| `t050v43` | 玉山 Hash Key | `ESUN_HASH` |
| `t050v34` | 發票特店 ID | `INV_MID` |
| `TP_APP_ID` | 趨勢支付 AppID | `TP_APP_ID` |
### 4. 回應範例 (JSON)
```json
{
"success": true,
"code": 200,
"data": [
{
"t050v01": "SN2026041301",
"api_token": "mac_token_...",
"t050v41": "8081234567",
"t050v42": "9001",
"t050v43": "password123",
"t050v34": "2000132",
"TP_APP_ID": "GREEN_001",
"...": "..."
}
]
}
```
---
## 🟢 B010: 心跳上報與狀態同步 (Heartbeat & Status) ## 🟢 B010: 心跳上報與狀態同步 (Heartbeat & Status)
機台定時(建議每 5-10 秒)上送,用於確認連線狀態、溫度及門禁狀態。 機台定時(建議每 5-10 秒)上送,用於確認連線狀態、溫度及門禁狀態。

43
docs/future_todo.md Normal file
View File

@@ -0,0 +1,43 @@
# Star Cloud 近期開發待辦清單 (Target Roadmap)
本文件列出了 Star Cloud 系統近期優先開發的功能模組,旨在強化系統的營運溝通能力與非同步處理效率。
---
## 🟢 核心開發階段:全域工具列與通訊系統
*本階段為目前唯一開發重心*
### 1. 全域工具列升級 (Header Toolbar)
| 功能項目 | 具體描述 | 預計開發時間 |
| :--- | :--- | :--- |
| **☁️ 下載任務中心** | 整合 Redis Queue 處理耗時報表匯出。商戶點擊匯出後背景執行,完成後透過 Header 圖示點擊下載。 | 2 天 |
| **🔔 通知中心** | 串接 Laravel Database Notifications顯示系統消息、機台警告與業務通知帶有紅點提示。 | 1 天 |
| **❓ 幫助/客服中心** | 於 Header 置入問號圖示,點擊觸發側邊抽屜 (Offcanvas),展示 FAQ 與客服聯繫窗口。 | 0.5 天 |
| **🎭 帳號切換與身分模擬** | **整合於頭像下拉選單**:支援「系統管理員切換租戶」與「租戶管理員切換子帳號」,提供顯眼的頂部模擬狀態橫幅。 | 1.5 天 |
### 2. 公告與溝通系統 (Communication System)
| 功能項目 | 具體描述 | 預計開發時間 |
| :--- | :--- | :--- |
| **📢 系統公告管理** | 建立後台發布介面,支援針對全體或特定租戶發布「一般」或「重要」公告。 | 1.5 天 |
| **🛡️ 登錄強制公告** | 實作具備「滑動解鎖」功能的彈窗。使用者必須將公告滑到底部,解鎖按鈕後才能進入 Dashboard。 | 1 天 |
### 3. 儀表板優化 (Dashboard Enhancement)
| 功能項目 | 具體描述 | 預計開發時間 |
| :--- | :--- | :--- |
| **🚀 儀表板快捷入口** | 在儀表板頂部加入一排快捷圖示(如:機台管理、訂單查詢、會員中心),方便商戶快速跳轉核心功能。 | 0.5 天 |
---
## 🟡 第二階段:進階行銷與營運工具
*優先順序:中 | 預計總工時:約 5 個開發日*
| 功能項目 | 具體描述 | 預計開發時間 |
| :--- | :--- | :--- |
| **🎁 互動盲盒抽獎** | **後台端**:實作中獎機率配置、獎項庫存管理、活動排程。**終端 API**:提供給機台大螢幕 H5/React 遊戲呼叫的開獎與配置介面。 | 4 天 |
---
## 📝 實作標準
1. **UI/UX**: 必須符合 `ui-minimal-luxury` 規範Outfit 字體、青色點綴、柔和投影)。
2. **安全性**: 權限控制必須嚴格過濾 `company_id`,公告需支援「已讀紀錄」追蹤。
3. **效能**: 下載中心必須使用非同步隊列,嚴禁在 Request 週期內執行耗時匯出。

View File

@@ -0,0 +1,191 @@
# Star Cloud MQTT 基礎架構實作計畫 (Phase 1)
本計畫旨在建立 Star Cloud 的高併發通訊基石,包含佈署 EMQX Broker、開發 Go MQTT Gateway並建立與 Laravel 之間的 Redis **雙向**異步橋接機制。
---
## User Review Required
> [!IMPORTANT]
> **雙向通訊架構**:本計畫不只處理「機台 ➜ 雲端」的上報,也同時建立「雲端 ➜ 機台」的指令下發通道。後台管理員在按下「遠端出貨」按鈕時Laravel 會將指令推入 Redis由 Go Gateway 轉發至 EMQX再即時送達機台 APP。
> [!WARNING]
> **資源配額**Go Gateway 雖然輕量,但在 Docker 環境中仍建議設定 `mem_limit` 避免極端情況下的資源爭搶。
---
## 系統架構總覽
```
機台 Android APP
├─ [上行] Publish ──→ EMQX ──→ Go Gateway ──→ Redis List (mqtt_incoming_jobs) ──→ Laravel mqtt:listen ──→ Job ──→ MySQL
└─ [下行] Subscribe ←── EMQX ←── Go Gateway ←── Redis List (mqtt_outgoing_commands) ←── Laravel MqttCommandService
```
---
## Proposed Changes
### 1. 基礎設施佈署 (Infrastructure)
#### [MODIFY] [compose.yaml](file:///home/mama/projects/star-cloud/compose.yaml)
- **新增 `emqx` 服務**
- Image: `emqx/emqx:5.10.3`開源版最終穩定版Apache 2.0 授權)
- Ports: `1883:1883` (MQTT), `8083:8083` (WebSocket), `18083:18083` (Dashboard)
- 加入 `sail` 網路
- 配置 Redis Auth 插件環境變數,指向 `star-cloud-redis`
- **新增 `mqtt-gateway` 服務**
- 使用 `mqtt-gateway/Dockerfile` 進行 Multi-stage build
- 連接至 `sail` 網路
- 依賴 `emqx``redis`
- 環境變數從 `.env` 讀取
#### [MODIFY] [.env](file:///home/mama/projects/star-cloud/.env)
- 新增以下環境變數:
```env
# MQTT / EMQX
MQTT_BROKER_HOST=emqx
MQTT_BROKER_PORT=1883
EMQX_DASHBOARD_PORT=18083
# Go Gateway
MQTT_GATEWAY_CLIENT_ID=star-cloud-gateway
MQTT_REDIS_HOST=star-cloud-redis
MQTT_REDIS_PORT=6379
```
---
### 2. Go MQTT Gateway 開發 (The Bridge)
#### [NEW] 完整目錄結構
```
mqtt-gateway/
├── Dockerfile ← Multi-stage build (builder + alpine)
├── go.mod
├── go.sum
├── main.go ← 進入點初始化、訊號監聽、graceful shutdown
├── config/
│ └── config.go ← 讀取環境變數 (EMQX_ADDR, REDIS_ADDR 等)
├── internal/
│ ├── handler/
│ │ ├── heartbeat_handler.go ← 處理 machine/+/heartbeat
│ │ ├── error_handler.go ← 處理 machine/+/error
│ │ └── transaction_handler.go ← 處理 machine/+/transaction
│ └── bridge/
│ ├── redis_consumer.go ← [下行] BLPOP mqtt_outgoing_commands轉發至 EMQX
│ └── redis_pusher.go ← [上行] RPUSH mqtt_incoming_jobs
```
#### [上行邏輯] 機台 ➜ 雲端
1. 訂閱 `machine/+/heartbeat`, `machine/+/error`, `machine/+/transaction`
2. 從 Topic 路徑提取 `serial_no`
3. 包裝成 `BridgePayload`
```json
{
"type": "heartbeat",
"serial_no": "M-001",
"payload": { "current_page": 1, "temperature": 25.5 },
"received_at": "2026-04-14T09:00:00+08:00"
}
```
4. 執行 `RPUSH mqtt_incoming_jobs {json}`
#### [下行邏輯] 雲端 ➜ 機台 (新增)
1. Go Gateway 啟動一條 Goroutine持續 `BLPOP mqtt_outgoing_commands`
2. 取得 JSON 後解析目標 `serial_no``command` 內容。
3. Publish 至 `machine/{serial_no}/command` (QoS 1)。
```json
{
"target": "M-001",
"command": "dispense",
"payload": { "slot_no": 5, "transaction_id": "T202604140001" },
"message_id": "MSG_123456789"
}
```
#### [NEW] mqtt-gateway/Dockerfile
```dockerfile
# Stage 1: Build
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o gateway .
# Stage 2: Run
FROM alpine:3.20
COPY --from=builder /app/gateway /usr/local/bin/gateway
CMD ["gateway"]
```
---
### 3. Laravel 端實作
#### [NEW] app/Console/Commands/ListenMqttQueue.php
- Artisan Command: `mqtt:listen`
- 使用 `BLPOP mqtt_incoming_jobs` 阻塞式監聽
- 根據 `type` 分派至對應的 Laravel Job
- `heartbeat``ProcessHeartbeatJob`
- `error``ProcessMachineErrorJob`
- `transaction``ProcessTransactionJob`
#### [NEW] app/Services/Machine/MqttCommandService.php
- 提供 `sendCommand(string $serialNo, string $command, array $payload)` 方法
- 將指令 JSON 推入 Redis List `mqtt_outgoing_commands`
- 供 Controller 呼叫(例如後台管理員按下「遠端出貨」按鈕)
#### [NEW] app/Jobs/Machine/ (三個 Job)
- `ProcessHeartbeatJob.php`: 更新 `last_heard_at`、溫度、頁面碼
- `ProcessMachineErrorJob.php`: 寫入 `machine_logs`,觸發告警通知
- `ProcessTransactionJob.php`: 更新庫存、建立交易紀錄
#### [MODIFY] [MachineAuthController.php](file:///home/mama/projects/star-cloud/app/Http/Controllers/Api/V1/App/MachineAuthController.php)
- 在 B014 核發 `api_token` 後,同步寫入 Redis
`Redis::set("machine_auth:{$serial_no}", hash('sha256', $token));`
- Token 更新/撤銷時,同步刪除 Redis 中的對應 Key
---
## Architecture Decisions (Confirmed)
### ✅ EMQX 版本策略
- **統一鎖定**:開發與正式環境皆使用 `emqx/emqx:5.10.3`(開源版最後一個穩定版本)。
- **選擇理由**EMQX 從 5.9.0 起合併為統一版本6.x 改用 BSL 商業授權。`5.10.3` 是純開源 (Apache 2.0) 的最終穩定版,功能完整且免授權費用。
### ✅ 下行指令安全性(無需額外 OTP
App 連線 MQTT 時已使用 `api_token` 作為密碼完成身份認證,因此 **MQTT 連線本身即為認證通道**。安全性由以下三層保障:
1. **連線層**App 使用 `serial_no` + `api_token` 連線 EMQX未通過驗證的裝置無法訂閱任何 Topic。
2. **ACL 層**EMQX 存取控制確保只有 Go Gateway 能 Publish 到 `machine/{id}/command`,機台 App 無法偽造指令。
3. **冪等性層**:每條下行指令的 Payload 包含唯一的 `message_id`App 端應記錄已執行的 ID防止重複執行同一條指令。
> [!NOTE]
> **結論**:上行用 Token 當密碼、下行用 ACL 當門禁、`message_id` 當防重複鎖。三層防護已足夠,不需要額外的 OTP 機制。
---
## Verification Plan
### 1. 基礎設施驗證
- 執行 `./vendor/bin/sail up -d`
- 造訪 `http://localhost:18083` 確認 EMQX Dashboard 正常啟動
- 確認 Go Gateway Container 的 logs 顯示 "Connected to EMQX" 與 "Connected to Redis"
### 2. 上行通訊測試 (機台 ➜ 雲端)
- 使用 MQTTX 工具連接 `localhost:1883`
- 發送心跳 JSON 至 `machine/TEST-001/heartbeat`
- 檢查 Laravel 日誌確認 `mqtt:listen` 成功接收並分派 Job
- 檢查 MySQL `machines` 表的 `last_heard_at` 是否更新
### 3. 下行通訊測試 (雲端 ➜ 機台)
- 在 MQTTX 訂閱 `machine/TEST-001/command`
- 透過 Laravel Tinker 呼叫 `MqttCommandService::sendCommand('TEST-001', 'reboot', [])`
- 確認 MQTTX 收到 reboot 指令的 JSON
### 4. 壓力測試
- 使用 Go Script 模擬 500 台機台同時發送心跳
- 監控 Redis List 長度與 Laravel Worker 的處理速率

View File

@@ -17,7 +17,7 @@
"Account updated successfully.": "Account updated successfully.", "Account updated successfully.": "Account updated successfully.",
"Account:": "Account:", "Account:": "Account:",
"accounts": "Account Management", "accounts": "Account Management",
"Accounts / Machines": "Accounts / Machines", "Accounts \/ Machines": "Accounts \/ Machines",
"Action": "Action", "Action": "Action",
"Actions": "Actions", "Actions": "Actions",
"Active": "Active", "Active": "Active",
@@ -51,7 +51,7 @@
"Advertisement Management": "Advertisement Management", "Advertisement Management": "Advertisement Management",
"Advertisement updated successfully": "Advertisement updated successfully", "Advertisement updated successfully": "Advertisement updated successfully",
"Advertisement updated successfully.": "Advertisement updated successfully.", "Advertisement updated successfully.": "Advertisement updated successfully.",
"Advertisement Video/Image": "Advertisement Video/Image", "Advertisement Video\/Image": "Advertisement Video\/Image",
"Affiliated Company": "Affiliated Company", "Affiliated Company": "Affiliated Company",
"Affiliated Unit": "Company Name", "Affiliated Unit": "Company Name",
"Affiliation": "Company Name", "Affiliation": "Company Name",
@@ -127,7 +127,7 @@
"Back to List": "Back to List", "Back to List": "Back to List",
"Badge Settings": "Badge Settings", "Badge Settings": "Badge Settings",
"Barcode": "Barcode", "Barcode": "Barcode",
"Barcode / Material": "Barcode / Material", "Barcode \/ Material": "Barcode \/ Material",
"Basic Information": "Basic Information", "Basic Information": "Basic Information",
"Basic Settings": "Basic Settings", "Basic Settings": "Basic Settings",
"Basic Specifications": "Basic Specifications", "Basic Specifications": "Basic Specifications",
@@ -164,7 +164,7 @@
"Change": "Change", "Change": "Change",
"Change Stock": "Change Stock", "Change Stock": "Change Stock",
"Channel Limits": "Channel Limits", "Channel Limits": "Channel Limits",
"Channel Limits (Track/Spring)": "Channel Limits (Track/Spring)", "Channel Limits (Track\/Spring)": "Channel Limits (Track\/Spring)",
"Channel Limits Configuration": "Channel Limits Configuration", "Channel Limits Configuration": "Channel Limits Configuration",
"ChannelId": "ChannelId", "ChannelId": "ChannelId",
"ChannelSecret": "ChannelSecret", "ChannelSecret": "ChannelSecret",
@@ -277,7 +277,7 @@
"Dispensing": "Dispensing", "Dispensing": "Dispensing",
"Duration": "Duration", "Duration": "Duration",
"Duration (Seconds)": "Duration (Seconds)", "Duration (Seconds)": "Duration (Seconds)",
"e.g. 500ml / 300g": "e.g. 500ml / 300g", "e.g. 500ml \/ 300g": "e.g. 500ml \/ 300g",
"e.g. John Doe": "e.g. John Doe", "e.g. John Doe": "e.g. John Doe",
"e.g. johndoe": "e.g. johndoe", "e.g. johndoe": "e.g. johndoe",
"e.g. Taiwan Star": "e.g. Taiwan Star", "e.g. Taiwan Star": "e.g. Taiwan Star",
@@ -315,7 +315,7 @@
"Enable Material Code": "Enable Material Code", "Enable Material Code": "Enable Material Code",
"Enable Points": "Enable Points", "Enable Points": "Enable Points",
"Enabled": "Enabled", "Enabled": "Enabled",
"Enabled/Disabled": "Enabled/Disabled", "Enabled\/Disabled": "Enabled\/Disabled",
"End Date": "End Date", "End Date": "End Date",
"Engineer": "Engineer", "Engineer": "Engineer",
"English": "English", "English": "English",
@@ -341,7 +341,7 @@
"Execution Time": "Execution Time", "Execution Time": "Execution Time",
"Exp": "Exp", "Exp": "Exp",
"Expired": "Expired", "Expired": "Expired",
"Expired / Disabled": "Expired / Disabled", "Expired \/ Disabled": "Expired \/ Disabled",
"Expiring": "Expiring", "Expiring": "Expiring",
"Expiry": "Expiry", "Expiry": "Expiry",
"Expiry Date": "Expiry Date", "Expiry Date": "Expiry Date",
@@ -463,6 +463,7 @@
"Machine Model Settings": "Machine Model Settings", "Machine Model Settings": "Machine Model Settings",
"Machine model updated successfully.": "Machine model updated successfully.", "Machine model updated successfully.": "Machine model updated successfully.",
"Machine Name": "Machine Name", "Machine Name": "Machine Name",
"Machine Serial No": "Machine Serial No",
"Machine Permissions": "Machine Permissions", "Machine Permissions": "Machine Permissions",
"Machine Reboot": "Machine Reboot", "Machine Reboot": "Machine Reboot",
"Machine Registry": "Machine Registry", "Machine Registry": "Machine Registry",
@@ -569,7 +570,7 @@
"Monthly cumulative revenue overview": "Monthly cumulative revenue overview", "Monthly cumulative revenue overview": "Monthly cumulative revenue overview",
"Monthly Transactions": "Monthly Transactions", "Monthly Transactions": "Monthly Transactions",
"Multilingual Names": "Multilingual Names", "Multilingual Names": "Multilingual Names",
"N/A": "N/A", "N\/A": "N\/A",
"Name": "Name", "Name": "Name",
"Name in English": "Name in English", "Name in English": "Name in English",
"Name in Japanese": "Name in Japanese", "Name in Japanese": "Name in Japanese",
@@ -615,6 +616,7 @@
"No roles available": "No roles available", "No roles available": "No roles available",
"No roles found.": "No roles found.", "No roles found.": "No roles found.",
"No slots found": "No slots found", "No slots found": "No slots found",
"No slot data available": "No slot data available",
"No users found": "No users found", "No users found": "No users found",
"None": "None", "None": "None",
"Normal": "Normal", "Normal": "Normal",
@@ -711,7 +713,7 @@
"Position": "Position", "Position": "Position",
"Preview": "Preview", "Preview": "Preview",
"Previous": "Previous", "Previous": "Previous",
"Price / Member": "Price / Member", "Price \/ Member": "Price \/ Member",
"Pricing Information": "Pricing Information", "Pricing Information": "Pricing Information",
"Product Count": "Product Count", "Product Count": "Product Count",
"Product created successfully": "Product created successfully", "Product created successfully": "Product created successfully",
@@ -823,7 +825,7 @@
"Scale level and access control": "層級與存取控制", "Scale level and access control": "層級與存取控制",
"Scan this code to quickly access the maintenance form for this device.": "Scan this code to quickly access the maintenance form for this device.", "Scan this code to quickly access the maintenance form for this device.": "Scan this code to quickly access the maintenance form for this device.",
"Search accounts...": "Search accounts...", "Search accounts...": "Search accounts...",
"Search by name or S/N...": "Search by name or S/N...", "Search by name or S\/N...": "Search by name or S\/N...",
"Search cargo lane": "Search cargo lane", "Search cargo lane": "Search cargo lane",
"Search Company Title...": "Search Company Title...", "Search Company Title...": "Search Company Title...",
"Search company...": "Search company...", "Search company...": "Search company...",
@@ -878,7 +880,6 @@
"Showing :from to :to of :total items": "Showing :from to :to of :total items", "Showing :from to :to of :total items": "Showing :from to :to of :total items",
"Sign in to your account": "Sign in to your account", "Sign in to your account": "Sign in to your account",
"Signed in as": "Signed in as", "Signed in as": "Signed in as",
"Slot": "Slot",
"Slot Mechanism (default: Conveyor, check for Spring)": "Slot Mechanism (default: Conveyor, check for Spring)", "Slot Mechanism (default: Conveyor, check for Spring)": "Slot Mechanism (default: Conveyor, check for Spring)",
"Slot No": "Slot No", "Slot No": "Slot No",
"Slot Status": "Slot Status", "Slot Status": "Slot Status",
@@ -900,10 +901,11 @@
"Start Date": "Start Date", "Start Date": "Start Date",
"Statistics": "Statistics", "Statistics": "Statistics",
"Status": "Status", "Status": "Status",
"Status / Temp / Sub / Card / Scan": "Status / Temp / Sub / Card / Scan", "Status \/ Temp \/ Sub \/ Card \/ Scan": "Status \/ Temp \/ Sub \/ Card \/ Scan",
"Stock": "Stock", "Stock": "Stock",
"Stock & Expiry": "Stock & Expiry", "Stock & Expiry": "Stock & Expiry",
"Stock & Expiry Management": "Stock & Expiry Management", "Stock & Expiry Management": "Stock & Expiry Management",
"Stock & Expiry Overview": "Stock & Expiry Overview",
"Stock Management": "Stock Management", "Stock Management": "Stock Management",
"Stock Quantity": "Stock Quantity", "Stock Quantity": "Stock Quantity",
"Stock:": "Stock:", "Stock:": "Stock:",
@@ -1025,6 +1027,7 @@
"Venue Management": "Venue Management", "Venue Management": "Venue Management",
"video": "video", "video": "video",
"View Details": "View Details", "View Details": "View Details",
"View Inventory": "View Inventory",
"View Logs": "View Logs", "View Logs": "View Logs",
"View More": "View More", "View More": "View More",
"Visit Gift": "Visit Gift", "Visit Gift": "Visit Gift",
@@ -1048,5 +1051,106 @@
"You cannot delete your own account.": "You cannot delete your own account.", "You cannot delete your own account.": "You cannot delete your own account.",
"Your email address is unverified.": "Your email address is unverified.", "Your email address is unverified.": "Your email address is unverified.",
"Your recent account activity": "Your recent account activity", "Your recent account activity": "Your recent account activity",
"待填寫": "待填寫" "待填寫": "待填寫",
"Dispensing in progress": "Dispensing in progress",
"Dispense successful": "Dispense successful",
"Slot jammed": "Slot jammed",
"Motor not stopped": "Motor not stopped",
"Slot not found": "Slot not found",
"Dispense error (0407)": "Dispense error (0407)",
"Dispense error (0408)": "Dispense error (0408)",
"Dispense error (0409)": "Dispense error (0409)",
"Dispense error (040A)": "Dispense error (040A)",
"Elevator rising": "Elevator rising",
"Elevator descending": "Elevator descending",
"Elevator rise error": "Elevator rise error",
"Elevator descent error": "Elevator descent error",
"Pickup door closed": "Pickup door closed",
"Pickup door error": "Pickup door error",
"Delivery door opened": "Delivery door opened",
"Delivery door open error": "Delivery door open error",
"Delivering product": "Delivering product",
"Delivery door closed": "Delivery door closed",
"Delivery door close error": "Delivery door close error",
"Hopper empty": "Hopper empty",
"Hopper overheated": "Hopper overheated",
"Hopper heating timeout": "Hopper heating timeout",
"Hopper error (0424)": "Hopper error (0424)",
"Microwave door opened": "Microwave door opened",
"Microwave door error": "Microwave door error",
"Dispense stopped": "Dispense stopped",
"Slot normal": "Slot normal",
"Product empty": "Product empty",
"Slot empty": "Slot empty",
"Slot not closed": "Slot not closed",
"Slot motor error (0207)": "Slot motor error (0207)",
"Slot motor error (0208)": "Slot motor error (0208)",
"Slot motor error (0209)": "Slot motor error (0209)",
"Hopper empty (0212)": "Hopper empty (0212)",
"Machine normal": "Machine normal",
"Elevator sensor error": "Elevator sensor error",
"Pickup door not closed": "Pickup door not closed",
"Elevator failure": "Elevator failure",
"Slot": "Slot",
"Page 0": "Offline",
"Page 1": "Home",
"Page 2": "Vending",
"Page 3": "Admin",
"Page 4": "Restock",
"Page 5": "Tutorial",
"Page 6": "Purchasing",
"Page 7": "Locked",
"Page 60": "Dispense Success",
"Page 61": "Slot Test",
"Page 62": "Payment Selection",
"Page 63": "Waiting for Payment",
"Page 64": "Dispensing",
"Page 65": "Receipt",
"Page 66": "Passcode",
"Page 67": "Pickup Code",
"Page 68": "Message",
"Page 69": "Purchase Cancelled",
"Page 610": "Purchase Ended",
"Page 611": "Store Gift",
"Page 612": "Dispense Failed",
"Door Opened": "Door Opened",
"Door Closed": "Door Closed",
"Firmware updated to :version": "Firmware updated to :version",
"Model changed to :model": "Model changed to :model",
"User logged in: :name": "User logged in: :name",
"Login failed: :account": "Login failed: :account",
"Unauthorized login attempt: :account": "Unauthorized login attempt: :account",
"Contract Model": "Contract Model",
"Warranty Service": "Warranty Service",
"Software Service": "Software Service",
"Modification History": "Modification History",
"No history records": "No history records",
"Initial contract registration": "Initial contract registration",
"Contract information updated": "Contract information updated",
"Warranty Start": "Warranty Start",
"Warranty End": "Warranty End",
"Software Start": "Software Start",
"Software End": "Software End",
"Contract History": "Contract History",
"Unlimited": "Unlimited",
"Change Note": "Change Note",
"Log Time": "Log Time",
"Service Periods": "Service Periods",
"Creator": "Creator",
"View Full History": "View Full History",
"Contract Start": "Contract Start",
"Contract End": "Contract End",
"Contract History Detail": "Contract History Detail",
"by": "by",
"Service Terms": "Service Periods",
"Contract": "Contract",
"Warranty": "Warranty",
"Software": "Software",
"Schedule": "Schedule",
"Immediate": "Immediate",
"Indefinite": "Indefinite",
"Ongoing": "Ongoing",
"Waiting": "Waiting",
"Publish Time": "Publish Time",
"Expired Time": "Expired Time"
} }

View File

@@ -17,7 +17,7 @@
"Account updated successfully.": "アカウントが正常に更新されました。", "Account updated successfully.": "アカウントが正常に更新されました。",
"Account:": "アカウント:", "Account:": "アカウント:",
"accounts": "アカウント管理", "accounts": "アカウント管理",
"Accounts / Machines": "アカウント / 機体", "Accounts \/ Machines": "アカウント \/ 機体",
"Action": "操作", "Action": "操作",
"Actions": "操作", "Actions": "操作",
"Active": "有効", "Active": "有効",
@@ -51,7 +51,7 @@
"Advertisement Management": "広告管理", "Advertisement Management": "広告管理",
"Advertisement updated successfully": "広告の更新に成功しました", "Advertisement updated successfully": "広告の更新に成功しました",
"Advertisement updated successfully.": "広告の更新に成功しました。", "Advertisement updated successfully.": "広告の更新に成功しました。",
"Advertisement Video/Image": "広告動画/画像", "Advertisement Video\/Image": "広告動画\/画像",
"Affiliated Company": "所属会社", "Affiliated Company": "所属会社",
"Affiliated Unit": "所属会社", "Affiliated Unit": "所属会社",
"Affiliation": "所属会社", "Affiliation": "所属会社",
@@ -127,7 +127,7 @@
"Back to List": "リストに戻る", "Back to List": "リストに戻る",
"Badge Settings": "バッジ設定", "Badge Settings": "バッジ設定",
"Barcode": "バーコード", "Barcode": "バーコード",
"Barcode / Material": "バーコード / 素材", "Barcode \/ Material": "バーコード \/ 素材",
"Basic Information": "基本情報", "Basic Information": "基本情報",
"Basic Settings": "基本設定", "Basic Settings": "基本設定",
"Basic Specifications": "基本仕様", "Basic Specifications": "基本仕様",
@@ -164,7 +164,7 @@
"Change": "変更", "Change": "変更",
"Change Stock": "在庫変更", "Change Stock": "在庫変更",
"Channel Limits": "チャネル制限", "Channel Limits": "チャネル制限",
"Channel Limits (Track/Spring)": "チャネル上限(トラック/スプリング)", "Channel Limits (Track\/Spring)": "チャネル上限(トラック\/スプリング)",
"Channel Limits Configuration": "チャネル制限設定", "Channel Limits Configuration": "チャネル制限設定",
"ChannelId": "チャネルID", "ChannelId": "チャネルID",
"ChannelSecret": "チャネルシークレット", "ChannelSecret": "チャネルシークレット",
@@ -277,7 +277,7 @@
"Dispensing": "払い出し中", "Dispensing": "払い出し中",
"Duration": "再生時間", "Duration": "再生時間",
"Duration (Seconds)": "再生時間(秒)", "Duration (Seconds)": "再生時間(秒)",
"e.g. 500ml / 300g": "例: 500ml / 300g", "e.g. 500ml \/ 300g": "例: 500ml \/ 300g",
"e.g. John Doe": "例:山田太郎", "e.g. John Doe": "例:山田太郎",
"e.g. johndoe": "例yamadataro", "e.g. johndoe": "例yamadataro",
"e.g. Taiwan Star": "例Taiwan Star", "e.g. Taiwan Star": "例Taiwan Star",
@@ -315,7 +315,7 @@
"Enable Material Code": "品目コードを有効にする", "Enable Material Code": "品目コードを有効にする",
"Enable Points": "ポイントを有効にする", "Enable Points": "ポイントを有効にする",
"Enabled": "有効", "Enabled": "有効",
"Enabled/Disabled": "有効/無効", "Enabled\/Disabled": "有効\/無効",
"End Date": "終了日", "End Date": "終了日",
"Engineer": "エンジニア", "Engineer": "エンジニア",
"English": "英語", "English": "英語",
@@ -341,7 +341,7 @@
"Execution Time": "実行時間", "Execution Time": "実行時間",
"Exp": "有効期限", "Exp": "有効期限",
"Expired": "期限切れ", "Expired": "期限切れ",
"Expired / Disabled": "期限切れ / 無効", "Expired \/ Disabled": "期限切れ \/ 無効",
"Expiring": "期限間近", "Expiring": "期限間近",
"Expiry": "期限", "Expiry": "期限",
"Expiry Date": "有効期限", "Expiry Date": "有効期限",
@@ -569,7 +569,7 @@
"Monthly cumulative revenue overview": "月間累積収益の概要", "Monthly cumulative revenue overview": "月間累積収益の概要",
"Monthly Transactions": "月間取引数", "Monthly Transactions": "月間取引数",
"Multilingual Names": "多言語名称", "Multilingual Names": "多言語名称",
"N/A": "N/A", "N\/A": "N\/A",
"Name": "名前", "Name": "名前",
"Name in English": "英語名", "Name in English": "英語名",
"Name in Japanese": "日本語名", "Name in Japanese": "日本語名",
@@ -615,6 +615,7 @@
"No roles available": "利用可能な権限はありません", "No roles available": "利用可能な権限はありません",
"No roles found.": "権限が見つかりません。", "No roles found.": "権限が見つかりません。",
"No slots found": "スロットが見つかりません", "No slots found": "スロットが見つかりません",
"No slot data available": "スロットデータがありません",
"No users found": "ユーザーが見つかりません", "No users found": "ユーザーが見つかりません",
"None": "なし", "None": "なし",
"Normal": "通常", "Normal": "通常",
@@ -629,7 +630,7 @@
"OEE.Hours": "時間", "OEE.Hours": "時間",
"OEE.Orders": "注文数", "OEE.Orders": "注文数",
"OEE.Sales": "売上高", "OEE.Sales": "売上高",
"of": "/", "of": "\/",
"Offline": "オフライン", "Offline": "オフライン",
"Offline Machines": "オフライン機体", "Offline Machines": "オフライン機体",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "アカウントを削除すると、そのすべてのリソースとデータが完全に削除されます。削除する前に、保存しておきたいデータをダウンロードしてください。", "Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "アカウントを削除すると、そのすべてのリソースとデータが完全に削除されます。削除する前に、保存しておきたいデータをダウンロードしてください。",
@@ -711,7 +712,7 @@
"Position": "位置", "Position": "位置",
"Preview": "プレビュー", "Preview": "プレビュー",
"Previous": "前へ", "Previous": "前へ",
"Price / Member": "価格 / 会員", "Price \/ Member": "価格 \/ 会員",
"Pricing Information": "価格情報", "Pricing Information": "価格情報",
"Product Count": "商品数", "Product Count": "商品数",
"Product created successfully": "商品が正常に作成されました", "Product created successfully": "商品が正常に作成されました",
@@ -823,7 +824,7 @@
"Scale level and access control": "規模レベルとアクセス制御", "Scale level and access control": "規模レベルとアクセス制御",
"Scan this code to quickly access the maintenance form for this device.": "このコードをスキャンして、端末のメンテナンスフォームに素早くアクセスします。", "Scan this code to quickly access the maintenance form for this device.": "このコードをスキャンして、端末のメンテナンスフォームに素早くアクセスします。",
"Search accounts...": "アカウントを検索...", "Search accounts...": "アカウントを検索...",
"Search by name or S/N...": "名称または製造番号で検索...", "Search by name or S\/N...": "名称または製造番号で検索...",
"Search cargo lane": "貨道を検索", "Search cargo lane": "貨道を検索",
"Search Company Title...": "会社名を検索...", "Search Company Title...": "会社名を検索...",
"Search company...": "会社を検索...", "Search company...": "会社を検索...",
@@ -900,10 +901,11 @@
"Start Date": "開始日", "Start Date": "開始日",
"Statistics": "統計", "Statistics": "統計",
"Status": "ステータス", "Status": "ステータス",
"Status / Temp / Sub / Card / Scan": "状態 / 温度 / 子機 / カード / スキャン", "Status \/ Temp \/ Sub \/ Card \/ Scan": "状態 \/ 温度 \/ 子機 \/ カード \/ スキャン",
"Stock": "在庫", "Stock": "在庫",
"Stock & Expiry": "在庫と消費期限", "Stock & Expiry": "在庫と消費期限",
"Stock & Expiry Management": "在庫・期限管理", "Stock & Expiry Management": "在庫・期限管理",
"Stock & Expiry Overview": "在庫・期限一覧",
"Stock Management": "在庫管理", "Stock Management": "在庫管理",
"Stock Quantity": "在庫数", "Stock Quantity": "在庫数",
"Stock:": "在庫:", "Stock:": "在庫:",
@@ -1025,6 +1027,7 @@
"Venue Management": "会場管理", "Venue Management": "会場管理",
"video": "video", "video": "video",
"View Details": "詳細を見る", "View Details": "詳細を見る",
"View Inventory": "在庫を見る",
"View Logs": "ログを見る", "View Logs": "ログを見る",
"View More": "もっと見る", "View More": "もっと見る",
"Visit Gift": "来店ギフト", "Visit Gift": "来店ギフト",
@@ -1048,5 +1051,105 @@
"You cannot delete your own account.": "自分自身のアカウントを削除することはできません。", "You cannot delete your own account.": "自分自身のアカウントを削除することはできません。",
"Your email address is unverified.": "メールアドレスが未認証です。", "Your email address is unverified.": "メールアドレスが未認証です。",
"Your recent account activity": "最近のアカウントアクティビティ", "Your recent account activity": "最近のアカウントアクティビティ",
"待填寫": "待填寫" "待填寫": "待填寫",
"Dispensing in progress": "商品搬送中",
"Dispense successful": "搬送成功",
"Slot jammed": "貨道詰まり (K-PDT)",
"Motor not stopped": "モーター停止異常",
"Slot not found": "指定貨道が見つかりません",
"Dispense error (0407)": "搬送異常 (0407)",
"Dispense error (0408)": "搬送異常 (0408)",
"Dispense error (0409)": "搬送異常 (0409)",
"Dispense error (040A)": "搬送異常 (040A)",
"Elevator rising": "昇降機上昇中",
"Elevator descending": "昇降機下降中",
"Elevator rise error": "昇降機上昇異常",
"Elevator descent error": "昇降機下降異常",
"Pickup door closed": "取出口ドア閉鎖",
"Pickup door error": "取出口ドア異常",
"Delivery door opened": "配送口ドア開放",
"Delivery door open error": "配送口ドア開放異常",
"Delivering product": "商品配送中",
"Delivery door closed": "配送口ドア閉鎖",
"Delivery door close error": "配送口ドア閉鎖異常",
"Hopper empty": "ホッパー空",
"Hopper overheated": "ホッパー過熱",
"Hopper heating timeout": "ホッパー加熱タイムアウト",
"Hopper error (0424)": "ホッパー異常 (0424)",
"Microwave door opened": "電子レンジドア開放",
"Microwave door error": "電子レンジドア異常",
"Dispense stopped": "搬送停止",
"Slot normal": "貨道正常",
"Product empty": "品切れ (PDT_EMPTY)",
"Slot empty": "貨道空 (SLOT_EMPTY)",
"Slot not closed": "貨道未閉鎖",
"Slot motor error (0207)": "貨道モーター故障 (0207)",
"Slot motor error (0208)": "貨道モーター故障 (0208)",
"Slot motor error (0209)": "貨道モーター故障 (0209)",
"Hopper empty (0212)": "ホッパー空 (0212)",
"Machine normal": "システム正常",
"Elevator sensor error": "昇降センサー異常",
"Pickup door not closed": "取出口ドア未閉鎖",
"Elevator failure": "昇降システム故障",
"Page 0": "オフライン",
"Page 1": "ホーム",
"Page 2": "販売ページ",
"Page 3": "管理ページ",
"Page 4": "補充ページ",
"Page 5": "チュートリアル",
"Page 6": "購入中",
"Page 7": "ロック中",
"Page 60": "排出成功",
"Page 61": "スロットテスト",
"Page 62": "支払い方法選択",
"Page 63": "支払い待ち",
"Page 64": "排出中",
"Page 65": "レシート",
"Page 66": "パスコード",
"Page 67": "受取コード",
"Page 68": "メッセージ",
"Page 69": "購入キャンセル",
"Page 610": "購入完了",
"Page 611": "来店ギフト",
"Page 612": "排出失敗",
"Door Opened": "機ドア開放",
"Door Closed": "機ドア閉鎖",
"Firmware updated to :version": "ファームウェア更新::version",
"Model changed to :model": "モデル変更::model",
"User logged in: :name": "ユーザーログイン::name",
"Login failed: :account": "ログイン失敗::account",
"Unauthorized login attempt: :account": "不許可のログイン試行::account",
"Contract Model": "契約モデル",
"Warranty Service": "保証サービス",
"Software Service": "ソフトウェアサービス",
"Modification History": "変更履歴",
"No history records": "履歴なし",
"Initial contract registration": "初期契約登録",
"Contract information updated": "契約情報更新",
"Warranty Start": "保証開始",
"Warranty End": "保証終了",
"Software Start": "ソフト開始",
"Software End": "ソフト終了",
"Contract History": "契約履歴",
"Unlimited": "無期限",
"Change Note": "変更メモ",
"Log Time": "記録時間",
"Service Periods": "サービス期間",
"Creator": "作成者",
"View Full History": "全履歴を表示",
"Contract Start": "契約開始",
"Contract End": "契約終了",
"Contract History Detail": "契約履歴の詳細",
"by": "作成者:",
"Service Terms": "サービス期間",
"Contract": "契約",
"Warranty": "保証",
"Software": "ソフトウェア",
"Schedule": "スケジュール設定",
"Immediate": "即時",
"Indefinite": "無期限",
"Ongoing": "進行中",
"Waiting": "待機中",
"Publish Time": "公開時間",
"Expired Time": "終了時間"
} }

View File

@@ -17,7 +17,7 @@
"Account updated successfully.": "帳號已成功更新。", "Account updated successfully.": "帳號已成功更新。",
"Account:": "帳號:", "Account:": "帳號:",
"accounts": "帳號管理", "accounts": "帳號管理",
"Accounts / Machines": "帳號 / 機台", "Accounts \/ Machines": "帳號 \/ 機台",
"Action": "操作", "Action": "操作",
"Actions": "操作", "Actions": "操作",
"Active": "使用中", "Active": "使用中",
@@ -51,7 +51,7 @@
"Advertisement Management": "廣告管理", "Advertisement Management": "廣告管理",
"Advertisement updated successfully": "廣告更新成功", "Advertisement updated successfully": "廣告更新成功",
"Advertisement updated successfully.": "廣告更新成功。", "Advertisement updated successfully.": "廣告更新成功。",
"Advertisement Video/Image": "廣告影片/圖片", "Advertisement Video\/Image": "廣告影片\/圖片",
"Affiliated Company": "公司名稱", "Affiliated Company": "公司名稱",
"Affiliated Unit": "公司名稱", "Affiliated Unit": "公司名稱",
"Affiliation": "所屬單位", "Affiliation": "所屬單位",
@@ -127,7 +127,7 @@
"Back to List": "返回列表", "Back to List": "返回列表",
"Badge Settings": "識別證", "Badge Settings": "識別證",
"Barcode": "條碼", "Barcode": "條碼",
"Barcode / Material": "條碼 / 物料編碼", "Barcode \/ Material": "條碼 \/ 物料編碼",
"Basic Information": "基本資訊", "Basic Information": "基本資訊",
"Basic Settings": "基本設定", "Basic Settings": "基本設定",
"Basic Specifications": "基本規格", "Basic Specifications": "基本規格",
@@ -164,7 +164,7 @@
"Change": "更換", "Change": "更換",
"Change Stock": "零錢庫存", "Change Stock": "零錢庫存",
"Channel Limits": "貨道上限", "Channel Limits": "貨道上限",
"Channel Limits (Track/Spring)": "貨道上限 (履帶/彈簧)", "Channel Limits (Track\/Spring)": "貨道上限 (履帶\/彈簧)",
"Channel Limits Configuration": "貨道上限配置", "Channel Limits Configuration": "貨道上限配置",
"ChannelId": "ChannelId", "ChannelId": "ChannelId",
"ChannelSecret": "ChannelSecret", "ChannelSecret": "ChannelSecret",
@@ -277,7 +277,7 @@
"Dispensing": "出貨", "Dispensing": "出貨",
"Duration": "時長", "Duration": "時長",
"Duration (Seconds)": "播放秒數", "Duration (Seconds)": "播放秒數",
"e.g. 500ml / 300g": "例如500ml / 300g", "e.g. 500ml \/ 300g": "例如500ml \/ 300g",
"e.g. John Doe": "例如:張曉明", "e.g. John Doe": "例如:張曉明",
"e.g. johndoe": "例如xiaoming", "e.g. johndoe": "例如xiaoming",
"e.g. Taiwan Star": "例如:台灣之星", "e.g. Taiwan Star": "例如:台灣之星",
@@ -315,7 +315,7 @@
"Enable Material Code": "啟用物料編號", "Enable Material Code": "啟用物料編號",
"Enable Points": "啟用點數規則", "Enable Points": "啟用點數規則",
"Enabled": "已啟用", "Enabled": "已啟用",
"Enabled/Disabled": "啟用/停用", "Enabled\/Disabled": "啟用\/停用",
"End Date": "截止日", "End Date": "截止日",
"Engineer": "維修人員", "Engineer": "維修人員",
"English": "英文", "English": "英文",
@@ -341,7 +341,7 @@
"Execution Time": "執行時間", "Execution Time": "執行時間",
"Exp": "效期", "Exp": "效期",
"Expired": "已過期", "Expired": "已過期",
"Expired / Disabled": "已過期 / 停用", "Expired \/ Disabled": "已過期 \/ 停用",
"Expiring": "效期將屆", "Expiring": "效期將屆",
"Expiry": "效期", "Expiry": "效期",
"Expiry Date": "有效日期", "Expiry Date": "有效日期",
@@ -463,6 +463,7 @@
"Machine Model Settings": "機台型號設定", "Machine Model Settings": "機台型號設定",
"Machine model updated successfully.": "機台型號已成功更新。", "Machine model updated successfully.": "機台型號已成功更新。",
"Machine Name": "機台名稱", "Machine Name": "機台名稱",
"Machine Serial No": "機台序號",
"Machine Permissions": "機台權限", "Machine Permissions": "機台權限",
"Machine Reboot": "機台重啟", "Machine Reboot": "機台重啟",
"Machine Registry": "機台清冊", "Machine Registry": "機台清冊",
@@ -569,7 +570,7 @@
"Monthly cumulative revenue overview": "本月累計營收概況", "Monthly cumulative revenue overview": "本月累計營收概況",
"Monthly Transactions": "本月交易統計", "Monthly Transactions": "本月交易統計",
"Multilingual Names": "多語系名稱", "Multilingual Names": "多語系名稱",
"N/A": "不適用", "N\/A": "不適用",
"Name": "名稱", "Name": "名稱",
"Name in English": "英文名稱", "Name in English": "英文名稱",
"Name in Japanese": "日文名稱", "Name in Japanese": "日文名稱",
@@ -615,6 +616,7 @@
"No roles available": "目前沒有角色資料。", "No roles available": "目前沒有角色資料。",
"No roles found.": "找不到角色資料。", "No roles found.": "找不到角色資料。",
"No slots found": "未找到貨道資訊", "No slots found": "未找到貨道資訊",
"No slot data available": "尚無貨道資料",
"No users found": "找不到用戶資料", "No users found": "找不到用戶資料",
"None": "無", "None": "無",
"Normal": "正常", "Normal": "正常",
@@ -675,7 +677,7 @@
"Pending": "等待機台領取", "Pending": "等待機台領取",
"pending": "等待機台領取", "pending": "等待機台領取",
"Performance": "效能 (Performance)", "Performance": "效能 (Performance)",
"Permanent": "永久授權", "Permanent": "永久",
"Permanently Delete Account": "永久刪除帳號", "Permanently Delete Account": "永久刪除帳號",
"Permission Settings": "權限設定", "Permission Settings": "權限設定",
"Permissions": "權限", "Permissions": "權限",
@@ -711,7 +713,7 @@
"Position": "投放位置", "Position": "投放位置",
"Preview": "預覽", "Preview": "預覽",
"Previous": "上一頁", "Previous": "上一頁",
"Price / Member": "售價 / 會員價", "Price \/ Member": "售價 \/ 會員價",
"Pricing Information": "價格資訊", "Pricing Information": "價格資訊",
"Product Count": "商品數量", "Product Count": "商品數量",
"Product created successfully": "商品已成功建立", "Product created successfully": "商品已成功建立",
@@ -823,7 +825,7 @@
"Scale level and access control": "層級與存取控制", "Scale level and access control": "層級與存取控制",
"Scan this code to quickly access the maintenance form for this device.": "掃描此 QR Code 即可快速進入此設備的維修單填寫頁面。", "Scan this code to quickly access the maintenance form for this device.": "掃描此 QR Code 即可快速進入此設備的維修單填寫頁面。",
"Search accounts...": "搜尋帳號...", "Search accounts...": "搜尋帳號...",
"Search by name or S/N...": "搜尋名稱或序號...", "Search by name or S\/N...": "搜尋名稱或序號...",
"Search cargo lane": "搜尋貨道編號或商品名稱", "Search cargo lane": "搜尋貨道編號或商品名稱",
"Search Company Title...": "搜尋公司名稱...", "Search Company Title...": "搜尋公司名稱...",
"Search company...": "搜尋公司...", "Search company...": "搜尋公司...",
@@ -878,7 +880,6 @@
"Showing :from to :to of :total items": "顯示第 :from 到 :to 項,共 :total 項", "Showing :from to :to of :total items": "顯示第 :from 到 :to 項,共 :total 項",
"Sign in to your account": "隨時隨地掌控您的業務。", "Sign in to your account": "隨時隨地掌控您的業務。",
"Signed in as": "登入身份", "Signed in as": "登入身份",
"Slot": "貨道",
"Slot Mechanism (default: Conveyor, check for Spring)": "貨道機制 (預設履帶,勾選為彈簧)", "Slot Mechanism (default: Conveyor, check for Spring)": "貨道機制 (預設履帶,勾選為彈簧)",
"Slot No": "貨道編號", "Slot No": "貨道編號",
"Slot Status": "貨道效期", "Slot Status": "貨道效期",
@@ -900,10 +901,11 @@
"Start Date": "起始日", "Start Date": "起始日",
"Statistics": "數據統計", "Statistics": "數據統計",
"Status": "狀態", "Status": "狀態",
"Status / Temp / Sub / Card / Scan": "狀態 / 溫度 / 下位機 / 刷卡機 / 掃碼機", "Status \/ Temp \/ Sub \/ Card \/ Scan": "狀態 \/ 溫度 \/ 下位機 \/ 刷卡機 \/ 掃碼機",
"Stock": "庫存", "Stock": "庫存",
"Stock & Expiry": "庫存與效期", "Stock & Expiry": "庫存與效期",
"Stock & Expiry Management": "庫存與效期管理", "Stock & Expiry Management": "庫存與效期管理",
"Stock & Expiry Overview": "庫存與效期一覽",
"Stock Management": "庫存管理單", "Stock Management": "庫存管理單",
"Stock Quantity": "庫存數量", "Stock Quantity": "庫存數量",
"Stock:": "庫存:", "Stock:": "庫存:",
@@ -1025,6 +1027,7 @@
"Venue Management": "場地管理", "Venue Management": "場地管理",
"video": "影片", "video": "影片",
"View Details": "查看詳情", "View Details": "查看詳情",
"View Inventory": "查看庫存",
"View Logs": "查看日誌", "View Logs": "查看日誌",
"View More": "查看更多", "View More": "查看更多",
"Visit Gift": "來店禮", "Visit Gift": "來店禮",
@@ -1048,5 +1051,106 @@
"You cannot delete your own account.": "您無法刪除自己的帳號。", "You cannot delete your own account.": "您無法刪除自己的帳號。",
"Your email address is unverified.": "您的電子郵件地址尚未驗證。", "Your email address is unverified.": "您的電子郵件地址尚未驗證。",
"Your recent account activity": "最近的帳號活動", "Your recent account activity": "最近的帳號活動",
"待填寫": "待填寫" "待填寫": "待填寫",
"Dispensing in progress": "正在出貨中",
"Dispense successful": "出貨成功",
"Slot jammed": "貨道卡貨 (K-PDT)",
"Motor not stopped": "電機未停止",
"Slot not found": "找不到指定貨道",
"Dispense error (0407)": "出貨過程異常 (0407)",
"Dispense error (0408)": "出貨過程異常 (0408)",
"Dispense error (0409)": "出貨過程異常 (0409)",
"Dispense error (040A)": "出貨過程異常 (040A)",
"Elevator rising": "升降平台上升中",
"Elevator descending": "升降平台下降中",
"Elevator rise error": "升降平台上升異常",
"Elevator descent error": "升降平台下降異常",
"Pickup door closed": "取貨門已關閉",
"Pickup door error": "取貨門運作異常",
"Delivery door opened": "送貨門開啟",
"Delivery door open error": "送貨門開啟異常",
"Delivering product": "正在送出商品",
"Delivery door closed": "送貨門關閉",
"Delivery door close error": "送貨門關閉異常",
"Hopper empty": "料斗箱空",
"Hopper overheated": "料斗箱過熱",
"Hopper heating timeout": "料斗箱加熱逾時",
"Hopper error (0424)": "料斗箱異常 (0424)",
"Microwave door opened": "微波爐門開啟",
"Microwave door error": "微波爐門異常",
"Dispense stopped": "出貨停止",
"Slot normal": "貨道正常",
"Product empty": "貨道缺貨 (PDT_EMPTY)",
"Slot empty": "貨道空 (SLOT_EMPTY)",
"Slot not closed": "貨道未關閉",
"Slot motor error (0207)": "貨道電機故障 (0207)",
"Slot motor error (0208)": "貨道電機故障 (0208)",
"Slot motor error (0209)": "貨道電機故障 (0209)",
"Hopper empty (0212)": "料斗空 (0212)",
"Machine normal": "機台系統正常",
"Elevator sensor error": "升降箱感測異常",
"Pickup door not closed": "取貨門未關閉",
"Elevator failure": "升降系統故障",
"Slot": "貨道",
"Page 0": "離線",
"Page 1": "主頁面",
"Page 2": "販賣頁",
"Page 3": "管理頁",
"Page 4": "補貨頁",
"Page 5": "教學頁",
"Page 6": "購買中",
"Page 7": "鎖定頁",
"Page 60": "出貨成功",
"Page 61": "貨道測試",
"Page 62": "付款選擇",
"Page 63": "等待付款",
"Page 64": "出貨",
"Page 65": "收據簽單",
"Page 66": "通行碼",
"Page 67": "取貨碼",
"Page 68": "訊息顯示",
"Page 69": "取消購買",
"Page 610": "購買結束",
"Page 611": "來店禮",
"Page 612": "出貨失敗",
"Door Opened": "機門已開啟",
"Door Closed": "機門已關閉",
"Firmware updated to :version": "韌體版本更新::version",
"Model changed to :model": "型號變更::model",
"User logged in: :name": "使用者登入::name",
"Login failed: :account": "登入失敗::account",
"Unauthorized login attempt: :account": "越權登入嘗試::account",
"Contract Model": "合約模式",
"Warranty Service": "保固服務",
"Software Service": "軟體服務",
"Modification History": "異動歷程",
"No history records": "尚無歷史紀錄",
"Initial contract registration": "初始合約註冊",
"Contract information updated": "合約資訊已更新",
"Warranty Start": "保固起始",
"Warranty End": "保固結束",
"Software Start": "軟體起始",
"Software End": "軟體結束",
"Contract History": "合約歷程",
"Unlimited": "無限期",
"Change Note": "異動備註",
"Log Time": "記錄時間",
"Service Periods": "服務區間",
"Creator": "建立者",
"View Full History": "查看完整歷程",
"Contract Start": "合約起始",
"Contract End": "合約結束",
"Contract History Detail": "合約歷程詳情",
"by": "由",
"Service Terms": "服務期程",
"Contract": "合約",
"Warranty": "保固",
"Software": "軟體",
"Schedule": "排程區間",
"Immediate": "立即",
"Indefinite": "無限期",
"Ongoing": "進行中",
"Waiting": "等待中",
"Publish Time": "發布時間",
"Expired Time": "下架時間"
} }

7
package-lock.json generated
View File

@@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"flatpickr": "^4.6.13",
"pptxgenjs": "^4.0.1" "pptxgenjs": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -1506,6 +1507,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/flatpickr": {
"version": "4.6.13",
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
"license": "MIT"
},
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",

View File

@@ -19,6 +19,7 @@
"vite": "^5.0.0" "vite": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"flatpickr": "^4.6.13",
"pptxgenjs": "^4.0.1" "pptxgenjs": "^4.0.1"
} }
} }

View File

@@ -1,3 +1,5 @@
@import 'flatpickr/dist/flatpickr.min.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -155,6 +157,17 @@
[x-cloak] { [x-cloak] {
display: none !important; display: none !important;
} }
/* Hide scrollbar for Chrome, Safari and Opera */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
} }
@layer components { @layer components {
@@ -317,4 +330,204 @@
.luxury-select-sm .hs-select-toggle { .luxury-select-sm .hs-select-toggle {
@apply py-2.5 text-sm !important; @apply py-2.5 text-sm !important;
} }
} }
/* Flatpickr Luxury Theme Overrides */
.flatpickr-calendar {
border: 1px solid #e2e8f0 !important;
border-radius: 1.25rem !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
background: #ffffff !important;
padding: 4px !important;
}
.dark .flatpickr-calendar {
background: #1e293b !important;
border-color: #334155 !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5) !important;
}
.flatpickr-day {
color: #475569 !important;
border-radius: 12px !important;
font-weight: 500 !important;
}
.dark .flatpickr-day {
color: #cbd5e1 !important;
}
.flatpickr-day.prevMonthDay,
.flatpickr-day.nextMonthDay {
color: #cbd5e1 !important;
opacity: 0.3;
}
.dark .flatpickr-day.prevMonthDay,
.dark .flatpickr-day.nextMonthDay {
color: #475569 !important;
opacity: 0.8;
}
.flatpickr-day.today {
border-color: #06b6d4 !important;
color: #06b6d4 !important;
}
.flatpickr-day.selected {
background: linear-gradient(135deg, #06b6d4, #3b82f6) !important;
border-color: transparent !important;
color: white !important;
box-shadow: 0 8px 15px -3px rgba(6, 182, 212, 0.4) !important;
}
.flatpickr-day:not(.selected):hover {
background: #f1f5f9 !important;
}
.dark .flatpickr-day:not(.selected):hover {
background: #334155 !important;
}
/* Weekdays & Header */
.flatpickr-weekday {
color: #94a3b8 !important;
font-weight: 800 !important;
font-size: 11px !important;
}
.dark .flatpickr-weekday {
color: #475569 !important;
}
.flatpickr-months .flatpickr-month {
color: #1e293b !important;
fill: currentColor !important;
}
.dark .flatpickr-months .flatpickr-month {
color: #f8fafc !important;
}
.flatpickr-prev-month,
.flatpickr-next-month {
color: #1e293b !important;
fill: currentColor !important;
@apply transition-colors duration-200 !important;
}
.dark .flatpickr-prev-month,
.dark .flatpickr-next-month {
color: #cbd5e1 !important;
}
.flatpickr-prev-month:hover,
.flatpickr-next-month:hover {
color: #06b6d4 !important;
}
.flatpickr-current-month .flatpickr-monthDropdown-months {
font-weight: 800 !important;
@apply bg-transparent dark:bg-slate-800 text-slate-900 dark:text-slate-100 !important;
border: none !important;
border-radius: 6px !important;
padding: 2px 4px !important;
cursor: pointer !important;
}
.flatpickr-monthDropdown-month {
@apply bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 !important;
}
/* Time Section */
.flatpickr-time {
border-top: 1px solid #f1f5f9 !important;
margin-top: 8px !important;
padding-top: 8px !important;
}
.dark .flatpickr-time {
border-top-color: #334155 !important;
}
.flatpickr-time input {
color: #1e293b !important;
font-weight: 800 !important;
border-radius: 8px !important;
}
.dark .flatpickr-time input {
color: #f8fafc !important;
}
.flatpickr-time input:hover,
.flatpickr-time input:focus {
background: #f1f5f9 !important;
}
.dark .flatpickr-time input:hover,
.dark .flatpickr-time input:focus {
background: #334155 !important;
}
.dark .flatpickr-time .flatpickr-am-pm {
color: #f8fafc !important;
}
/* Time Stepper Arrows & Separator */
.flatpickr-time .numInputWrapper span.arrowUp:after {
border-bottom-color: #94a3b8 !important;
}
.flatpickr-time .numInputWrapper span.arrowUp:hover:after {
border-bottom-color: #06b6d4 !important;
}
.flatpickr-time .numInputWrapper span.arrowDown:after {
border-top-color: #94a3b8 !important;
}
.flatpickr-time .numInputWrapper span.arrowDown:hover:after {
border-top-color: #06b6d4 !important;
}
.dark .flatpickr-time .numInputWrapper span.arrowUp:after {
border-bottom-color: #64748b !important;
}
.dark .flatpickr-time .numInputWrapper span.arrowUp:hover:after {
border-bottom-color: #06b6d4 !important;
}
.dark .flatpickr-time .numInputWrapper span.arrowDown:after {
border-top-color: #64748b !important;
}
.dark .flatpickr-time .numInputWrapper span.arrowDown:hover:after {
border-top-color: #06b6d4 !important;
}
.flatpickr-time .numInputWrapper span {
border-color: #f1f5f9 !important;
}
.dark .flatpickr-time .numInputWrapper span {
border-color: #334155 !important;
background: transparent !important;
}
.dark .flatpickr-time .numInputWrapper span:hover {
background: #334155 !important;
}
.flatpickr-time .flatpickr-time-separator {
color: #94a3b8 !important;
}
.dark .flatpickr-time .flatpickr-time-separator {
color: #64748b !important;
}
.flatpickr-weekdays {
background: transparent !important;
}

View File

@@ -3,11 +3,32 @@ import './bootstrap';
import Alpine from 'alpinejs'; import Alpine from 'alpinejs';
import collapse from '@alpinejs/collapse'; import collapse from '@alpinejs/collapse';
// 初始化 Preline UI
import 'preline';
// 引入 Flatpickr 與語系
import flatpickr from "flatpickr";
import { MandarinTraditional } from "flatpickr/dist/l10n/zh-tw.js";
import { Japanese } from "flatpickr/dist/l10n/ja.js";
const docLang = document.documentElement.lang.toLowerCase();
if (docLang.includes('zh')) {
flatpickr.localize(MandarinTraditional);
window.flatpickrLocale = MandarinTraditional;
} else if (docLang.includes('ja')) {
flatpickr.localize(Japanese);
window.flatpickrLocale = Japanese;
} else {
// English is the default in flatpickr
window.flatpickrLocale = 'default';
}
window.flatpickr = flatpickr;
Alpine.plugin(collapse); Alpine.plugin(collapse);
window.Alpine = Alpine; window.Alpine = Alpine;
// 確保其他套件都初始化完成後再啟動 Alpine
Alpine.start(); Alpine.start();
// 初始化 Preline UI
import 'preline';

View File

@@ -71,6 +71,7 @@ $baseRoute = 'admin.data-config.advertisements';
@endif @endif
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Type') }}</th> <th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Type') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Duration') }}</th> <th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Duration') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Schedule') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th> <th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th> <th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr> </tr>
@@ -104,15 +105,34 @@ $baseRoute = 'admin.data-config.advertisements';
{{ __($ad->type) }} {{ __($ad->type) }}
</span> </span>
</td> </td>
<td class="px-6 py-4 text-center whitespace-nowrap text-sm font-black text-slate-700 dark:text-slate-200"> <td class="px-6 py-4 text-center whitespace-nowrap text-sm font-mono font-bold text-slate-700 dark:text-slate-200">
{{ $ad->duration }}s {{ $ad->duration }}s
</td> </td>
<td class="px-6 py-4 text-center whitespace-nowrap">
<div class="flex flex-col items-center gap-0.5">
<span class="text-[11px] font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-tight">{{ __('From') }}: {{ $ad->start_at?->format('Y-m-d H:i') ?? __('Immediate') }}</span>
<span class="text-[11px] font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-tight">{{ __('To') }}: {{ $ad->end_at?->format('Y-m-d H:i') ?? __('Indefinite') }}</span>
</div>
</td>
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
@if($ad->is_active) @php
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">{{ __('Active') }}</span> $now = now();
@else $isStarted = !$ad->start_at || $ad->start_at <= $now;
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">{{ __('Disabled') }}</span> $isExpired = $ad->end_at && $ad->end_at < $now;
@endif $isPlaying = $ad->is_active && $isStarted && !$isExpired;
@endphp
<div class="flex flex-col items-center gap-1">
@if(!$ad->is_active)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-slate-500/10 text-slate-500 border border-slate-500/20 tracking-widest uppercase">{{ __('Disabled') }}</span>
@elseif($isExpired)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">{{ __('Expired') }}</span>
@elseif(!$isStarted)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-amber-500/10 text-amber-500 border border-amber-500/20 tracking-widest uppercase">{{ __('Waiting') }}</span>
@else
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">{{ __('Ongoing') }}</span>
@endif
</div>
</td> </td>
<td class="px-6 py-4 text-right"> <td class="px-6 py-4 text-right">
<div class="flex justify-end items-center gap-2"> <div class="flex justify-end items-center gap-2">
@@ -211,7 +231,7 @@ $baseRoute = 'admin.data-config.advertisements';
</button> </button>
<div class="flex-1 min-w-0 flex flex-col justify-center cursor-pointer group-hover:text-cyan-500 transition-colors" @click="openPreview(assign.advertisement)"> <div class="flex-1 min-w-0 flex flex-col justify-center cursor-pointer group-hover:text-cyan-500 transition-colors" @click="openPreview(assign.advertisement)">
<p class="text-xs font-black text-slate-700 dark:text-white truncate transition-colors" x-text="assign.advertisement.name"></p> <p class="text-xs font-black text-slate-700 dark:text-white truncate transition-colors" x-text="assign.advertisement.name"></p>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter mt-0.5" x-text="assign.advertisement.duration + 's'"></p> <p class="text-[11px] font-mono font-bold text-slate-400 uppercase tracking-tight mt-0.5" x-text="assign.advertisement.duration + 's'"></p>
</div> </div>
<button @click="removeAssignment(assign.id)" class="p-1.5 text-slate-300 hover:text-rose-500 transition-colors"> <button @click="removeAssignment(assign.id)" class="p-1.5 text-slate-300 hover:text-rose-500 transition-colors">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg> <svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
@@ -324,7 +344,7 @@ $baseRoute = 'admin.data-config.advertisements';
<span class="w-1.5 h-1.5 rounded-full bg-white/20"></span> <span class="w-1.5 h-1.5 rounded-full bg-white/20"></span>
<span class="text-cyan-400 font-black tracking-widest text-xs uppercase" x-text="(currentSequenceIndex + 1) + ' / ' + sequenceAds.length"></span> <span class="text-cyan-400 font-black tracking-widest text-xs uppercase" x-text="(currentSequenceIndex + 1) + ' / ' + sequenceAds.length"></span>
<span class="w-1.5 h-1.5 rounded-full bg-white/20"></span> <span class="w-1.5 h-1.5 rounded-full bg-white/20"></span>
<span class="text-white/80 font-bold tracking-widest text-xs tabular-nums" x-text="Math.ceil(sequenceRemainingTime) + 's'"></span> <span class="text-white/80 font-mono font-bold tracking-tight text-xs tabular-nums" x-text="Math.ceil(sequenceRemainingTime) + 's'"></span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -385,7 +405,9 @@ $baseRoute = 'admin.data-config.advertisements';
type: 'image', type: 'image',
duration: 15, duration: 15,
is_active: true, is_active: true,
url: '' url: '',
start_at: '',
end_at: ''
}, },
// Assign Modal // Assign Modal
@@ -604,6 +626,17 @@ $baseRoute = 'admin.data-config.advertisements';
} }
}, },
formatDateForInput(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}`;
},
async submitAssignment() { async submitAssignment() {
try { try {
const response = await fetch(this.urls.assign, { const response = await fetch(this.urls.assign, {
@@ -650,6 +683,14 @@ $baseRoute = 'admin.data-config.advertisements';
if (document.querySelector('#ad_company_select')) { if (document.querySelector('#ad_company_select')) {
window.HSSelect.getInstance('#ad_company_select')?.setValue(this.adForm.company_id || ''); window.HSSelect.getInstance('#ad_company_select')?.setValue(this.adForm.company_id || '');
} }
// 確保 Flatpickr 實例同步顯示目前的時間值
if (this.$refs.startAtPicker?._flatpickr) {
this.$refs.startAtPicker._flatpickr.setDate(this.adForm.start_at);
}
if (this.$refs.endAtPicker?._flatpickr) {
this.$refs.endAtPicker._flatpickr.setDate(this.adForm.end_at);
}
}); });
} }
}); });
@@ -685,7 +726,7 @@ $baseRoute = 'admin.data-config.advertisements';
openAddModal() { openAddModal() {
this.adFormMode = 'add'; this.adFormMode = 'add';
this.adForm = { id: null, company_id: '', name: '', type: 'image', duration: 15, is_active: true, url: '' }; this.adForm = { id: null, company_id: '', name: '', type: 'image', duration: 15, is_active: true, url: '', start_at: '', end_at: '' };
this.fileName = ''; this.fileName = '';
this.mediaPreview = null; this.mediaPreview = null;
this.isAdModalOpen = true; this.isAdModalOpen = true;
@@ -693,7 +734,11 @@ $baseRoute = 'admin.data-config.advertisements';
openEditModal(ad) { openEditModal(ad) {
this.adFormMode = 'edit'; this.adFormMode = 'edit';
this.adForm = { ...ad }; this.adForm = {
...ad,
start_at: this.formatDateForInput(ad.start_at),
end_at: this.formatDateForInput(ad.end_at)
};
this.fileName = ''; this.fileName = '';
this.mediaPreview = ad.url; // Use existing URL as preview this.mediaPreview = ad.url; // Use existing URL as preview
this.isAdModalOpen = true; this.isAdModalOpen = true;

View File

@@ -94,6 +94,51 @@
</div> </div>
</div> </div>
<!-- Scheduling -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
{{ __('Publish Time') }}
</label>
<div class="relative group/input">
<input type="text" name="start_at" x-ref="startAtPicker" x-model="adForm.start_at"
x-init="flatpickr($refs.startAtPicker, {
enableTime: true,
dateFormat: 'Y/m/d H:i',
time_24hr: true,
locale: window.flatpickrLocale,
onClose: (selectedDates, dateStr) => { adForm.start_at = dateStr; }
})"
class="w-full h-12 bg-slate-50 dark:bg-slate-800/50 border-none rounded-xl px-4 pr-10 text-sm font-bold text-slate-800 dark:text-white focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-400"
placeholder="YYYY/MM/DD HH:MM">
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 group-hover/input:text-cyan-500 transition-colors pointer-events-none">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
{{ __('Expired Time') }}
</label>
<div class="relative group/input">
<input type="text" name="end_at" x-ref="endAtPicker" x-model="adForm.end_at"
x-init="flatpickr($refs.endAtPicker, {
enableTime: true,
dateFormat: 'Y/m/d H:i',
time_24hr: true,
locale: window.flatpickrLocale,
onClose: (selectedDates, dateStr) => { adForm.end_at = dateStr; }
})"
class="w-full h-12 bg-slate-50 dark:bg-slate-800/50 border-none rounded-xl px-4 pr-10 text-sm font-bold text-slate-800 dark:text-white focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-400"
placeholder="YYYY/MM/DD HH:MM">
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 group-hover/input:text-cyan-500 transition-colors pointer-events-none">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
</div>
</div>
</div>
<!-- File Upload (Luxury UI Pattern) --> <!-- File Upload (Luxury UI Pattern) -->
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1"> <label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">

View File

@@ -102,8 +102,8 @@
<input type="text" name="name" value="{{ old('name', $machine->name) }}" class="luxury-input w-full" required> <input type="text" name="name" value="{{ old('name', $machine->name) }}" class="luxury-input w-full" required>
</div> </div>
<div> <div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Serial Number') }}</label> <label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Machine Serial No') }} <span class="text-rose-500">*</span></label>
<input type="text" value="{{ $machine->serial_no }}" class="luxury-input w-full bg-slate-50/50 dark:bg-slate-900/50 text-slate-400 cursor-not-allowed" readonly> <input type="text" name="serial_no" value="{{ old('serial_no', $machine->serial_no) }}" class="luxury-input w-full" required>
</div> </div>
<div> <div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Location') }}</label> <label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Location') }}</label>

View File

@@ -2,8 +2,11 @@
@section('content') @section('content')
<div class="space-y-6" x-data="{ <div class="space-y-6" x-data="{
showModal: false, showModal: false,
showHistoryModal: false,
editing: false, editing: false,
sidebarView: 'detail',
currentCompany: { currentCompany: {
id: '', id: '',
name: '', name: '',
@@ -16,6 +19,10 @@
contact_email: '', contact_email: '',
start_date: '', start_date: '',
end_date: '', end_date: '',
warranty_start_date: '',
warranty_end_date: '',
software_start_date: '',
software_end_date: '',
status: 1, status: 1,
note: '', note: '',
settings: { settings: {
@@ -28,7 +35,10 @@
this.currentCompany = { this.currentCompany = {
id: '', name: '', code: '', original_type: 'lease', current_type: 'lease', id: '', name: '', code: '', original_type: 'lease', current_type: 'lease',
tax_id: '', contact_name: '', contact_phone: '', tax_id: '', contact_name: '', contact_phone: '',
contact_email: '', start_date: '', end_date: '', status: 1, note: '', contact_email: '', start_date: '', end_date: '',
warranty_start_date: '', warranty_end_date: '',
software_start_date: '', software_end_date: '',
status: 1, note: '',
settings: { enable_material_code: false, enable_points: false } settings: { enable_material_code: false, enable_points: false }
}; };
this.showModal = true; this.showModal = true;
@@ -39,6 +49,10 @@
...company, ...company,
start_date: company.start_date ? company.start_date.substring(0, 10) : '', start_date: company.start_date ? company.start_date.substring(0, 10) : '',
end_date: company.end_date ? company.end_date.substring(0, 10) : '', end_date: company.end_date ? company.end_date.substring(0, 10) : '',
warranty_start_date: company.warranty_start_date ? company.warranty_start_date.substring(0, 10) : '',
warranty_end_date: company.warranty_end_date ? company.warranty_end_date.substring(0, 10) : '',
software_start_date: company.software_start_date ? company.software_start_date.substring(0, 10) : '',
software_end_date: company.software_end_date ? company.software_end_date.substring(0, 10) : '',
settings: { settings: {
enable_material_code: company.settings?.enable_material_code || false, enable_material_code: company.settings?.enable_material_code || false,
enable_points: company.settings?.enable_points || false enable_points: company.settings?.enable_points || false
@@ -57,9 +71,13 @@
detailCompany: { detailCompany: {
id: '', name: '', code: '', original_type: 'lease', current_type: 'lease', id: '', name: '', code: '', original_type: 'lease', current_type: 'lease',
tax_id: '', contact_name: '', contact_phone: '', contact_email: '', tax_id: '', contact_name: '', contact_phone: '', contact_email: '',
start_date: '', end_date: '', status: 1, note: '', start_date: '', end_date: '',
warranty_start_date: '', warranty_end_date: '',
software_start_date: '', software_end_date: '',
status: 1, note: '',
settings: { enable_material_code: false, enable_points: false }, settings: { enable_material_code: false, enable_points: false },
users_count: 0, machines_count: 0 users_count: 0, machines_count: 0,
contracts: []
}, },
openDetailSidebar(company) { openDetailSidebar(company) {
this.detailCompany = { this.detailCompany = {
@@ -69,15 +87,40 @@
enable_points: company.settings?.enable_points || false enable_points: company.settings?.enable_points || false
} }
}; };
this.sidebarView = 'detail';
this.showDetail = true; this.showDetail = true;
}, },
openHistorySidebar(company) {
this.detailCompany = {
...company,
settings: {
enable_material_code: company.settings?.enable_material_code || false,
enable_points: company.settings?.enable_points || false
}
};
this.sidebarView = 'history';
this.showDetail = true;
},
openFullHistory() {
this.showHistoryModal = true;
},
openHistory(company) {
this.detailCompany = {
...company,
settings: {
enable_material_code: company.settings?.enable_material_code || false,
enable_points: company.settings?.enable_points || false
}
};
this.showHistoryModal = true;
},
submitConfirmedForm() { submitConfirmedForm() {
if (this.statusToggleSource === 'list') { if (this.statusToggleSource === 'list') {
this.$refs.statusToggleForm.submit(); this.$refs.statusToggleForm.submit();
} else { } else {
this.$refs.companyForm.submit(); this.$refs.companyForm.submit();
} }
} },
}"> }">
<!-- Header --> <!-- Header -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
@@ -164,7 +207,7 @@
{{ __('Accounts / Machines') }}</th> {{ __('Accounts / Machines') }}</th>
<th <th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center"> class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Contract Period') }}</th> {{ __('Service Terms') }}</th>
<th <th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-right"> class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Actions') }}</th> {{ __('Actions') }}</th>
@@ -235,20 +278,53 @@
</div> </div>
</div> </div>
</td> </td>
<td class="px-6 py-6 text-center text-slate-500 dark:text-slate-400"> <td class="px-6 py-6 border-b border-slate-50 dark:border-slate-800/50">
<div class="flex flex-col items-center gap-1.5 font-mono"> <div class="flex flex-col gap-2.5 min-w-[200px] mx-auto w-fit">
<div class="flex items-center gap-2 min-w-[130px] justify-center"> <!-- Contract Period (Only for Lease) -->
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">{{ __('From:') }}</span> @if($company->current_type === 'lease')
<span class="text-[13px] font-bold tracking-tighter text-slate-600 dark:text-slate-300"> <div class="flex items-center gap-3 group/term">
{{ $company->start_date ? $company->start_date->format('Y-m-d') : '--' }} <span class="px-2 py-0.5 rounded-md bg-blue-500/10 text-blue-600 text-[10px] font-black border border-blue-500/20 group-hover/term:bg-blue-500 group-hover/term:text-white transition-all tracking-wider whitespace-nowrap min-w-[42px] text-center">
{{ __('Contract') }}
</span> </span>
<div class="flex items-center gap-1.5 font-mono text-[11px] font-bold">
<span class="text-slate-400">{{ $company->start_date ? $company->start_date->format('Y-m-d') : '--' }}</span>
<span class="text-slate-300">~</span>
<span class="{{ $company->end_date && $company->end_date->isPast() ? 'text-rose-500 shadow-sm shadow-rose-500/10' : 'text-slate-600 dark:text-slate-300' }}">
{{ $company->end_date ? $company->end_date->format('Y-m-d') : __('Permanent') }}
</span>
</div>
</div> </div>
<div class="flex items-center gap-2 min-w-[130px] justify-center"> @endif
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">{{ __('To:') }}</span>
<span class="text-[13px] font-bold tracking-tighter {{ $company->end_date && $company->end_date->isPast() ? 'text-rose-500' : 'text-slate-800 dark:text-slate-200' }}"> @if($company->current_type === 'buyout')
{{ $company->end_date ? $company->end_date->format('Y-m-d') : __('Permanent') }} <!-- Warranty Period -->
<div class="flex items-center gap-3 group/term">
<span class="px-2 py-0.5 rounded-md bg-amber-500/10 text-amber-600 text-[10px] font-black border border-amber-500/20 group-hover/term:bg-amber-500 group-hover/term:text-white transition-all tracking-wider whitespace-nowrap min-w-[42px] text-center">
{{ __('Warranty') }}
</span> </span>
<div class="flex items-center gap-1.5 font-mono text-[11px] font-bold">
<span class="text-slate-400">{{ $company->warranty_start_date ? $company->warranty_start_date->format('Y-m-d') : '--' }}</span>
<span class="text-slate-300">~</span>
<span class="{{ $company->warranty_end_date && $company->warranty_end_date->isPast() ? 'text-rose-500 shadow-sm shadow-rose-500/10' : 'text-slate-500 dark:text-slate-400' }}">
{{ $company->warranty_end_date ? $company->warranty_end_date->format('Y-m-d') : '--' }}
</span>
</div>
</div> </div>
<!-- Software Period -->
<div class="flex items-center gap-3 group/term">
<span class="px-2 py-0.5 rounded-md bg-purple-500/10 text-purple-600 text-[10px] font-black border border-purple-500/20 group-hover/term:bg-purple-500 group-hover/term:text-white transition-all tracking-wider whitespace-nowrap min-w-[42px] text-center">
{{ __('Software') }}
</span>
<div class="flex items-center gap-1.5 font-mono text-[11px] font-bold">
<span class="text-slate-400">{{ $company->software_start_date ? $company->software_start_date->format('Y-m-d') : '--' }}</span>
<span class="text-slate-300">~</span>
<span class="{{ $company->software_end_date && $company->software_end_date->isPast() ? 'text-rose-500 shadow-sm shadow-rose-500/10' : 'text-slate-500 dark:text-slate-400' }}">
{{ $company->software_end_date ? $company->software_end_date->format('Y-m-d') : '--' }}
</span>
</div>
</div>
@endif
</div> </div>
</td> </td>
<td class="px-6 py-6 text-right"> <td class="px-6 py-6 text-right">
@@ -447,11 +523,11 @@
<input type="text" name="tax_id" x-model="currentCompany.tax_id" <input type="text" name="tax_id" x-model="currentCompany.tax_id"
class="luxury-input w-full"> class="luxury-input w-full">
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3" x-show="currentCompany.current_type === 'lease'">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Start Date') }} <span class="text-rose-500 ml-0.5">*</span></label> __('Start Date') }} <span class="text-rose-500 ml-0.5">*</span></label>
<input type="date" name="start_date" x-model="currentCompany.start_date" required <input type="date" name="start_date" x-model="currentCompany.start_date" :required="currentCompany.current_type === 'lease'"
class="luxury-input w-full px-2"> class="luxury-input w-full px-2">
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
@@ -462,6 +538,38 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Buyout Specific Dates -->
<div x-show="currentCompany.current_type === 'buyout'" x-transition class="space-y-4 pt-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<label class="text-[10px] font-black text-amber-600 uppercase tracking-widest pl-1">{{ __('Warranty Start') }}</label>
<input type="date" name="warranty_start_date" x-model="currentCompany.warranty_start_date"
class="luxury-input w-full px-2 border-amber-100 dark:border-amber-900/30">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-amber-600 uppercase tracking-widest pl-1">{{ __('Warranty End') }}</label>
<input type="date" name="warranty_end_date" x-model="currentCompany.warranty_end_date"
class="luxury-input w-full px-2 border-amber-100 dark:border-amber-900/30">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<label class="text-[10px] font-black text-indigo-600 uppercase tracking-widest pl-1">{{ __('Software Start') }}</label>
<input type="date" name="software_start_date" x-model="currentCompany.software_start_date"
class="luxury-input w-full px-2 border-indigo-100 dark:border-indigo-900/30">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-indigo-600 uppercase tracking-widest pl-1">{{ __('Software End') }}</label>
<input type="date" name="software_end_date" x-model="currentCompany.software_end_date"
class="luxury-input w-full px-2 border-indigo-100 dark:border-indigo-900/30">
</div>
</div>
</div>
<!-- Keep hidden start_date for buyout as well if needed by backend -->
<input type="hidden" name="start_date" :value="currentCompany.start_date" x-if="currentCompany.current_type === 'buyout' && !currentCompany.start_date">
</div>
</div> </div>
<!-- Admin Account Section (Account Creation) - Only show when creating --> <!-- Admin Account Section (Account Creation) - Only show when creating -->
@@ -652,7 +760,7 @@
<!-- Header --> <!-- Header -->
<div class="px-8 py-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between bg-white dark:bg-slate-900 sticky top-0 z-10"> <div class="px-8 py-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between bg-white dark:bg-slate-900 sticky top-0 z-10">
<div> <div>
<h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Customer Details') }}</h2> <h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight" x-text="sidebarView === 'history' ? '{{ __('Contract History Detail') }}' : '{{ __('Customer Details') }}'"></h2>
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
<p class="text-xs font-bold text-slate-400 uppercase tracking-[0.2em]" x-text="detailCompany.name"></p> <p class="text-xs font-bold text-slate-400 uppercase tracking-[0.2em]" x-text="detailCompany.name"></p>
<span class="text-xs font-mono font-black text-cyan-500 px-1.5 py-0.5 bg-cyan-500/10 rounded" x-text="detailCompany.code"></span> <span class="text-xs font-mono font-black text-cyan-500 px-1.5 py-0.5 bg-cyan-500/10 rounded" x-text="detailCompany.code"></span>
@@ -666,7 +774,19 @@
</div> </div>
<!-- Body --> <!-- Body -->
<div class="flex-1 overflow-y-auto px-8 py-8 space-y-8 custom-scrollbar"> <div class="flex-1 overflow-y-auto px-8 pt-4 pb-8 space-y-5 custom-scrollbar">
<!-- Tab Switcher -->
<div class="flex gap-1 p-1 bg-slate-100 dark:bg-slate-800/60 rounded-xl">
<button @click="sidebarView = 'detail'"
:class="sidebarView === 'detail' ? 'bg-white dark:bg-slate-700 text-slate-800 dark:text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'"
class="flex-1 py-2.5 px-3 text-xs font-black uppercase tracking-[0.15em] rounded-lg transition-all">{{ __('Customer Details') }}</button>
<button @click="sidebarView = 'history'"
:class="sidebarView === 'history' ? 'bg-white dark:bg-slate-700 text-slate-800 dark:text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'"
class="flex-1 py-2.5 px-3 text-xs font-black uppercase tracking-[0.15em] rounded-lg transition-all">{{ __('Contract History Detail') }}</button>
</div>
<!-- Detail View -->
<div x-show="sidebarView === 'detail'" class="space-y-8">
<!-- Validity & Status Section --> <!-- Validity & Status Section -->
<section class="space-y-4"> <section class="space-y-4">
<h3 class="text-xs font-black text-emerald-500 uppercase tracking-[0.3em]">{{ __('Account Status') }}</h3> <h3 class="text-xs font-black text-emerald-500 uppercase tracking-[0.3em]">{{ __('Account Status') }}</h3>
@@ -690,38 +810,56 @@
</div> </div>
<!-- Business Type --> <!-- Business Type -->
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80"> <div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80 space-y-4">
<h4 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.2em] mb-4">{{ __('Business Type') }}</h4> <div class="flex justify-between items-start pb-3 border-b border-slate-100/50 dark:border-slate-800/50">
<div class="space-y-4"> <div>
<div class="flex items-center justify-between"> <h4 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.2em]">{{ __('Contract Model') }}</h4>
<span class="text-[11px] font-bold text-slate-400">{{ __('Original:') }}</span>
<span class="px-2.5 py-1 rounded-lg text-[11px] font-bold uppercase tracking-widest"
:class="detailCompany.original_type === 'buyout' ? 'bg-amber-500/10 text-amber-600' : 'bg-blue-500/10 text-blue-600'"
x-text="detailCompany.original_type === 'buyout' ? '{{ __('Buyout') }}' : '{{ __('Lease') }}'"></span>
</div>
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-slate-400">{{ __('Current:') }}</span>
<span class="px-2.5 py-1 rounded-lg text-[11px] font-bold uppercase tracking-widest"
:class="detailCompany.current_type === 'buyout' ? 'bg-amber-500/10 text-amber-600 border border-amber-500/20' : 'bg-blue-500/10 text-blue-600 border border-blue-500/20'"
x-text="detailCompany.current_type === 'buyout' ? '{{ __('Buyout') }}' : '{{ __('Lease') }}'"></span>
</div> </div>
<span :class="detailCompany.current_type === 'lease' ? 'text-blue-500 bg-blue-500/10' : 'text-amber-500 bg-amber-500/10'"
class="text-[10px] font-black uppercase tracking-wider px-2.5 py-1 rounded-lg">
<span x-text="detailCompany.current_type === 'lease' ? '{{ __('Lease') }}' : '{{ __('Buyout') }}'"></span>
</span>
</div> </div>
<!-- Lease Info Display -->
<template x-if="detailCompany.current_type === 'lease'">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">{{ __('Start Date') }}</p>
<p class="text-xs font-black text-slate-700 dark:text-slate-200" x-text="detailCompany.start_date?.substring(0, 10) || '-'"></p>
</div>
<div class="space-y-1">
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">{{ __('End Date') }}</p>
<p class="text-xs font-black text-slate-800 dark:text-white" x-text="detailCompany.end_date?.substring(0, 10) || '{{ __('Unlimited') }}'"></p>
</div>
</div>
</template>
<!-- Buyout Info Display -->
<template x-if="detailCompany.current_type === 'buyout'">
<div class="space-y-4">
<div class="grid grid-cols-1 gap-3">
<div class="p-3 bg-amber-50/30 dark:bg-amber-500/5 rounded-xl border border-amber-100/50 dark:border-amber-500/10">
<p class="text-[9px] font-black text-amber-600 uppercase tracking-widest">{{ __('Warranty Service') }}</p>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-black text-slate-700 dark:text-slate-200" x-text="detailCompany.warranty_start_date?.substring(0, 10) || '-'"></span>
<span class="text-slate-400">~</span>
<span class="text-xs font-black text-slate-800 dark:text-white" x-text="detailCompany.warranty_end_date?.substring(0, 10) || '-'"></span>
</div>
</div>
<div class="p-3 bg-indigo-50/30 dark:bg-indigo-500/5 rounded-xl border border-indigo-100/50 dark:border-indigo-500/10">
<p class="text-[9px] font-black text-indigo-600 uppercase tracking-widest">{{ __('Software Service') }}</p>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-black text-slate-700 dark:text-slate-200" x-text="detailCompany.software_start_date?.substring(0, 10) || '-'"></span>
<span class="text-slate-400">~</span>
<span class="text-xs font-black text-slate-800 dark:text-white" x-text="detailCompany.software_end_date?.substring(0, 10) || '-'"></span>
</div>
</div>
</div>
</div>
</template>
</div> </div>
<!-- Contract Period -->
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
<h4 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.2em] mb-4">{{ __('Contract Period') }}</h4>
<div class="space-y-3 font-mono">
<div class="flex items-center gap-3">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter min-w-[32px]">{{ __('From:') }}</span>
<div class="text-[13px] font-bold tracking-tighter text-slate-700 dark:text-slate-200" x-text="detailCompany.start_date || '--'"></div>
</div>
<div class="flex items-center gap-3">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter min-w-[32px]">{{ __('To:') }}</span>
<div class="text-[13px] font-bold tracking-tighter text-slate-800 dark:text-white" :class="detailCompany.end_date_expired ? 'text-rose-500' : ''" x-text="detailCompany.end_date || '{{ __('Permanent') }}'"></div>
</div>
</div>
</div>
</div> </div>
</section> </section>
@@ -803,6 +941,82 @@
<p class="text-sm font-bold text-slate-600 dark:text-slate-400 leading-relaxed italic" x-text="detailCompany.note"></p> <p class="text-sm font-bold text-slate-600 dark:text-slate-400 leading-relaxed italic" x-text="detailCompany.note"></p>
</div> </div>
</section> </section>
</div>
<!-- History View -->
<div x-show="sidebarView === 'history'" class="space-y-6">
<template x-for="(contract, index) in (detailCompany.contracts || [])" :key="contract.id">
<div class="bg-slate-50/50 dark:bg-slate-800/30 rounded-2xl border border-slate-100 dark:border-slate-800/80 overflow-hidden">
<!-- Card Header -->
<div class="px-5 py-3 bg-white dark:bg-slate-800/50 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<div class="flex items-center gap-2.5">
<div class="size-7 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-[9px] font-black text-slate-500" x-text="'#' + (detailCompany.contracts.length - index)"></div>
<div>
<p class="text-[10px] font-black text-slate-700 dark:text-slate-200" x-text="new Date(contract.created_at).toLocaleString()"></p>
<div class="flex items-center gap-1.5 mt-0.5">
<span :class="contract.type === 'lease' ? 'text-blue-500 bg-blue-500/10' : 'text-amber-500 bg-amber-500/10'"
class="text-[8px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded"
x-text="contract.type === 'lease' ? '{{ __('Lease') }}' : '{{ __('Buyout') }}'"></span>
<span class="text-[9px] text-slate-400">{{ __('by') }} <span class="text-slate-600 dark:text-slate-300" x-text="contract.creator?.name || 'System'"></span></span>
</div>
</div>
</div>
<template x-if="index === 0">
<span class="px-2 py-0.5 bg-emerald-500 text-white text-[8px] font-black uppercase tracking-widest rounded-full">{{ __('Current') }}</span>
</template>
</div>
<!-- Card Body -->
<div class="p-5 space-y-4">
<!-- Lease dates -->
<template x-if="contract.type === 'lease'">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Contract Start') }}</p>
<p class="text-sm font-black text-slate-700 dark:text-white font-mono" x-text="contract.start_date?.substring(0, 10) || '-'"></p>
</div>
<div class="space-y-1.5">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Contract End') }}</p>
<p class="text-sm font-black text-slate-700 dark:text-white font-mono" x-text="contract.end_date?.substring(0, 10) || '{{ __('Unlimited') }}'"></p>
</div>
</div>
</template>
<!-- Buyout dates -->
<template x-if="contract.type === 'buyout'">
<div class="grid grid-cols-1 gap-4">
<div class="p-4 bg-amber-50/40 dark:bg-amber-500/5 rounded-2xl border border-amber-100/50 dark:border-amber-500/10">
<p class="text-[10px] font-black text-amber-600 uppercase tracking-widest mb-2">{{ __('Warranty Service') }}</p>
<div class="flex items-center gap-2">
<span class="text-sm font-black text-slate-700 dark:text-slate-200 font-mono" x-text="contract.warranty_start_date?.substring(0, 10) || '-'"></span>
<span class="text-slate-400 font-bold">~</span>
<span class="text-sm font-black text-slate-800 dark:text-white font-mono" x-text="contract.warranty_end_date?.substring(0, 10) || '-'"></span>
</div>
</div>
<div class="p-4 bg-indigo-50/40 dark:bg-indigo-500/5 rounded-2xl border border-indigo-100/50 dark:border-indigo-500/10">
<p class="text-[10px] font-black text-indigo-600 uppercase tracking-widest mb-2">{{ __('Software Service') }}</p>
<div class="flex items-center gap-2">
<span class="text-sm font-black text-slate-700 dark:text-slate-200 font-mono" x-text="contract.software_start_date?.substring(0, 10) || '-'"></span>
<span class="text-slate-400 font-bold">~</span>
<span class="text-sm font-black text-slate-800 dark:text-white font-mono" x-text="contract.software_end_date?.substring(0, 10) || '-'"></span>
</div>
</div>
</div>
</template>
<!-- Note -->
<div class="pt-2 border-t border-slate-100/50 dark:border-slate-800/50" x-show="contract.note">
<p class="text-[9px] font-bold text-slate-400 italic" x-text="contract.note"></p>
</div>
</div>
</div>
</template>
<div x-show="!(detailCompany.contracts || []).length" class="py-12 text-center">
<div class="size-14 rounded-2xl bg-slate-50 dark:bg-slate-800/50 flex items-center justify-center mx-auto mb-3 border border-slate-100 dark:border-slate-800">
<svg class="size-7 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18c-2.305 0-4.408.867-6 2.292m0-14.25v14.25" /></svg>
</div>
<p class="text-xs font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('No history records') }}</p>
</div>
</div>
</div> </div>
<!-- Footer --> <!-- Footer -->
@@ -815,7 +1029,7 @@
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
<style> <style>

View File

@@ -95,7 +95,10 @@ $roleSelectConfig = [
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg> </svg>
</span> </span>
<input type="text" name="search" value="{{ request('search') }}" class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input" placeholder="{{ __('Search roles...') }}"> <input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search roles...') }}"
@keydown.enter="$el.form.submit()">
</div> </div>
@if(auth()->user()->isSystemAdmin()) @if(auth()->user()->isSystemAdmin())
@@ -106,6 +109,7 @@ $roleSelectConfig = [
</div> </div>
@endif @endif
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}"> <input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
<button type="submit" class="hidden"></button>
</form> </form>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -211,7 +215,8 @@ $roleSelectConfig = [
</span> </span>
<input type="text" name="search" value="{{ request('search') }}" <input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input" class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search users...') }}"> placeholder="{{ __('Search users...') }}"
@keydown.enter="$el.form.submit()">
</div> </div>
@if(auth()->user()->isSystemAdmin()) @if(auth()->user()->isSystemAdmin())
@@ -222,6 +227,7 @@ $roleSelectConfig = [
</div> </div>
@endif @endif
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}"> <input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
<button type="submit" class="hidden"></button>
</form> </form>
<div class="overflow-x-auto"> <div class="overflow-x-auto">

View File

@@ -7,6 +7,7 @@
return { return {
showLogPanel: false, showLogPanel: false,
showEditModal: false, showEditModal: false,
showInventoryPanel: false,
editMachineId: '', editMachineId: '',
editMachineName: '', editMachineName: '',
activeTab: 'status', activeTab: 'status',
@@ -15,23 +16,25 @@
currentMachineName: '', currentMachineName: '',
logs: [], logs: [],
loading: false, loading: false,
inventoryLoading: false,
startDate: '', startDate: '',
endDate: '', endDate: '',
tab: 'list', tab: 'list',
viewMode: 'fleet', viewMode: 'fleet',
selectedMachine: null, selectedMachine: null,
slots: [], slots: [],
inventorySlots: [],
currentPage: 1,
lastPage: 1,
init() { init() {
const d = new Date(); const now = new Date();
const today = [ const pad = (n) => String(n).padStart(2, '0');
d.getFullYear(), const formatDate = (date, time) => `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${time}`;
String(d.getMonth() + 1).padStart(2, '0'),
String(d.getDate()).padStart(2, '0') this.startDate = formatDate(now, '00:00');
].join('-'); this.endDate = formatDate(now, '23:59');
this.startDate = today; this.$watch('activeTab', () => this.fetchLogs(1));
this.endDate = today;
this.$watch('activeTab', () => this.fetchLogs());
}, },
async openLogPanel(id, sn, name) { async openLogPanel(id, sn, name) {
@@ -51,15 +54,21 @@
}, },
async fetchLogs() { async fetchLogs(page = 1) {
this.loading = true; this.loading = true;
this.currentPage = page;
try { try {
let url = '/admin/machines/' + this.currentMachineId + '/logs-ajax?type=' + this.activeTab; let url = '/admin/machines/' + this.currentMachineId + '/logs-ajax?type=' + this.activeTab + '&page=' + page;
if (this.startDate) url += '&start_date=' + this.startDate; if (this.startDate) url += '&start_date=' + this.startDate;
if (this.endDate) url += '&end_date=' + this.endDate; if (this.endDate) url += '&end_date=' + this.endDate;
const res = await fetch(url); const res = await fetch(url);
const data = await res.json(); const data = await res.json();
if (data.success) this.logs = data.data.data || data.data || []; if (data.success) {
this.logs = data.data || [];
this.currentPage = data.pagination.current_page;
this.lastPage = data.pagination.last_page;
}
} catch (e) { console.error('fetchLogs error:', e); } } catch (e) { console.error('fetchLogs error:', e); }
finally { this.loading = false; } finally { this.loading = false; }
}, },
@@ -79,11 +88,51 @@
finally { this.loading = false; } finally { this.loading = false; }
}, },
// 庫存一覽面板 (唯讀)
async openInventoryPanel(id, sn, name) {
this.currentMachineId = id;
this.currentMachineSn = sn;
this.currentMachineName = name;
this.inventorySlots = [];
this.showInventoryPanel = true;
this.inventoryLoading = true;
try {
const res = await fetch('/admin/machines/' + id + '/slots-ajax');
const data = await res.json();
if (data.success) {
this.inventorySlots = data.slots;
}
} catch (e) { console.error('openInventoryPanel error:', e); }
finally { this.inventoryLoading = false; }
},
formatDateTime(dateStr) {
if (!dateStr) return '--';
const d = new Date(dateStr);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
},
getSlotColorClass(slot) {
if (!slot.expiry_date) return 'bg-slate-50/50 dark:bg-slate-800/50 text-slate-400 border-slate-200/60 dark:border-slate-700/50';
const todayStr = new Date().toISOString().split('T')[0];
const expiryStr = slot.expiry_date;
if (expiryStr < todayStr) {
return 'bg-rose-50/60 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-200 dark:border-rose-500/30 shadow-sm shadow-rose-500/5';
}
const diffDays = Math.round((new Date(expiryStr) - new Date(todayStr)) / 86400000);
if (diffDays <= 7) {
return 'bg-amber-50/60 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-500/30 shadow-sm shadow-amber-500/5';
}
return 'bg-emerald-50/60 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/30 shadow-sm shadow-emerald-500/5';
},
}; };
}; };
</script> </script>
<div class="space-y-4 pb-20 mt-4" x-data="machineApp()" @keydown.escape.window="showLogPanel = false"> <div class="space-y-4 pb-20 mt-4" x-data="machineApp()"
@keydown.escape.window="showLogPanel = false; showInventoryPanel = false">
<!-- Top Header & Actions --> <!-- Top Header & Actions -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@@ -243,6 +292,16 @@
</td> </td>
<td class="px-6 py-6 text-right"> <td class="px-6 py-6 text-right">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<button type="button"
@click="openInventoryPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip"
title="{{ __('View Inventory') }}">
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</button>
<button type="button" <button type="button"
@click="openEditModal('{{ $machine->id }}', '{{ addslashes($machine->name) }}')" @click="openEditModal('{{ $machine->id }}', '{{ addslashes($machine->name) }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip" class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip"
@@ -260,7 +319,9 @@
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" <svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /> d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg> </svg>
</button> </button>
</div> </div>
@@ -342,19 +403,35 @@
<label <label
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{ class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{
__('From') }}</label> __('From') }}</label>
<input type="date" x-model="startDate" @change="fetchLogs()" <input type="text" x-ref="startDatePicker" x-model="startDate"
class="luxury-input text-[11px] h-9 sm:h-8 py-0 w-full sm:w-32 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700"> x-init="flatpickr($refs.startDatePicker, {
enableTime: true,
dateFormat: 'Y/m/d H:i',
time_24hr: true,
locale: window.flatpickrLocale,
defaultDate: startDate,
onClose: (selectedDates, dateStr) => { startDate = dateStr; fetchLogs(1); }
})"
class="luxury-input text-[11px] h-9 sm:h-8 py-0 w-full sm:w-44 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700">
</div> </div>
<div class="flex flex-col sm:flex-row sm:items-center gap-1.5 sm:gap-2"> <div class="flex flex-col sm:flex-row sm:items-center gap-1.5 sm:gap-2">
<label <label
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{ class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{
__('To') }}</label> __('To') }}</label>
<input type="date" x-model="endDate" @change="fetchLogs()" <input type="text" x-ref="endDatePicker" x-model="endDate"
class="luxury-input text-[11px] h-9 sm:h-8 py-0 w-full sm:w-32 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700"> x-init="flatpickr($refs.endDatePicker, {
enableTime: true,
dateFormat: 'Y/m/d H:i',
time_24hr: true,
locale: window.flatpickrLocale,
defaultDate: endDate,
onClose: (selectedDates, dateStr) => { endDate = dateStr; fetchLogs(1); }
})"
class="luxury-input text-[11px] h-9 sm:h-8 py-0 w-full sm:w-44 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700">
</div> </div>
</div> </div>
<div class="flex justify-start sm:justify-end"> <div class="flex justify-start sm:justify-end">
<button @click="startDate = ''; endDate = ''; fetchLogs()" <button @click="startDate = ''; endDate = ''; fetchLogs(1)"
class="text-[10px] font-bold text-cyan-600 dark:text-cyan-400 uppercase tracking-widest hover:text-cyan-500 transition-colors flex items-center gap-1.5"> class="text-[10px] font-bold text-cyan-600 dark:text-cyan-400 uppercase tracking-widest hover:text-cyan-500 transition-colors flex items-center gap-1.5">
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5"> stroke-width="2.5">
@@ -371,7 +448,7 @@
<!-- Body / Navigation Tabs --> <!-- Body / Navigation Tabs -->
<div class="flex-1 flex flex-col min-h-0"> <div class="flex-1 flex flex-col min-h-0">
<div <div
class="border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 sticky top-0 z-10 px-6 sm:px-8 overflow-x-auto hide-scrollbar"> class="border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 sticky top-0 z-10 px-6 sm:px-8 overflow-x-auto overflow-y-hidden hide-scrollbar">
<nav class="-mb-px flex space-x-6 sm:space-x-8" aria-label="Tabs"> <nav class="-mb-px flex space-x-6 sm:space-x-8" aria-label="Tabs">
<button @click="activeTab = 'status'" <button @click="activeTab = 'status'"
:class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'status', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'status'}" :class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'status', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'status'}"
@@ -438,7 +515,7 @@
class="group hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-all"> class="group hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-all">
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="text-[12px] font-bold text-slate-600 dark:text-slate-300" <div class="text-[12px] font-bold text-slate-600 dark:text-slate-300"
x-text="new Date(log.created_at).toLocaleString()"> x-text="formatDateTime(log.created_at)">
</div> </div>
</td> </td>
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
@@ -453,7 +530,7 @@
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<p class="text-[13px] font-medium text-slate-700 dark:text-slate-200 truncate max-w-md" <p class="text-[13px] font-medium text-slate-700 dark:text-slate-200 truncate max-w-md"
:title="log.message" x-text="log.message"></p> :title="log.translated_message || log.message" x-text="log.translated_message || log.message"></p>
</td> </td>
</tr> </tr>
</template> </template>
@@ -469,7 +546,7 @@
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span <span
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest" class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest"
x-text="new Date(log.created_at).toLocaleString()"></span> x-text="formatDateTime(log.created_at)"></span>
<span :class="{ <span :class="{
'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20': log.level === 'info', 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20': log.level === 'info',
'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20': log.level === 'warning', 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20': log.level === 'warning',
@@ -480,7 +557,7 @@
</span> </span>
</div> </div>
<p class="text-[13px] font-bold text-slate-700 dark:text-slate-200 line-clamp-3 leading-relaxed" <p class="text-[13px] font-bold text-slate-700 dark:text-slate-200 line-clamp-3 leading-relaxed"
x-text="log.message"></p> x-text="log.translated_message || log.message"></p>
</div> </div>
</template> </template>
</div> </div>
@@ -506,6 +583,33 @@
</div> </div>
</div> </div>
</template> </template>
<!-- Pagination Footer -->
<div x-show="logs.length > 0" class="px-6 py-4 bg-slate-50/50 dark:bg-slate-800/30 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between">
<div class="flex items-center gap-4">
<button @click="fetchLogs(currentPage - 1)"
:disabled="currentPage <= 1"
class="p-2 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-400 disabled:opacity-30 disabled:cursor-not-allowed hover:text-cyan-500 hover:border-cyan-500/30 transition-all shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="flex items-center gap-1.5">
<span class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-widest" x-text="currentPage"></span>
<span class="text-[10px] font-black text-slate-300 dark:text-slate-600 uppercase">/</span>
<span class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest" x-text="lastPage"></span>
</div>
<button @click="fetchLogs(currentPage + 1)"
:disabled="currentPage >= lastPage"
class="p-2 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-400 disabled:opacity-30 disabled:cursor-not-allowed hover:text-cyan-500 hover:border-cyan-500/30 transition-all shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div> </div>
</div> </div>
@@ -519,20 +623,21 @@
</div><!-- /Offcanvas --> </div><!-- /Offcanvas -->
<!-- Edit Machine Name Modal --> <!-- Edit Machine Name Modal -->
<div x-show="showEditModal" class="fixed inset-0 z-[100] overflow-y-auto" style="display: none;" role="dialog" aria-modal="true"> <div x-show="showEditModal" class="fixed inset-0 z-[100] overflow-y-auto" style="display: none;" role="dialog"
aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"> <div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<!-- Background Backdrop --> <!-- Background Backdrop -->
<div x-show="showEditModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" <div x-show="showEditModal" 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:enter-end="opacity-100" x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" 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="showEditModal = false"> class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
@click="showEditModal = false">
</div> </div>
<span class="hidden sm:inline-block sm:align-middle sm:min-h-screen" aria-hidden="true">&#8203;</span> <span class="hidden sm:inline-block sm:align-middle sm:min-h-screen" aria-hidden="true">&#8203;</span>
<!-- Modal Panel --> <!-- Modal Panel -->
<div x-show="showEditModal" <div x-show="showEditModal" x-transition:enter="ease-out duration-300"
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-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:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200" x-transition:leave="ease-in duration-200"
@@ -541,15 +646,20 @@
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-[2.5rem] p-10 text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-slate-100 dark:border-slate-800"> class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-[2.5rem] p-10 text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-slate-100 dark:border-slate-800">
<div> <div>
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none mb-2">{{ __('Edit Machine Name') }}</h3> <h3
<p class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Update identification for your asset') }}</p> class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none mb-2">
{{ __('Edit Machine Name') }}</h3>
<p class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{
__('Update identification for your asset') }}</p>
<form :action="'/admin/machines/' + editMachineId" method="POST" class="mt-8 space-y-6"> <form :action="'/admin/machines/' + editMachineId" method="POST" class="mt-8 space-y-6">
@csrf @csrf
@method('PUT') @method('PUT')
<div class="space-y-4"> <div class="space-y-4">
<label class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.1em]">{{ __('New Machine Name') }}</label> <label
class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.1em]">{{
__('New Machine Name') }}</label>
<input type="text" name="name" x-model="editMachineName" required <input type="text" name="name" x-model="editMachineName" required
class="luxury-input block w-full px-6 py-4 text-base font-bold text-slate-800 dark:text-white bg-slate-50/50 dark:bg-slate-900/50" class="luxury-input block w-full px-6 py-4 text-base font-bold text-slate-800 dark:text-white bg-slate-50/50 dark:bg-slate-900/50"
placeholder="{{ __('Enter machine name...') }}"> placeholder="{{ __('Enter machine name...') }}">
@@ -571,4 +681,228 @@
</div> </div>
</div><!-- /Edit Modal --> </div><!-- /Edit Modal -->
<!-- Inventory Offcanvas Panel (唯讀庫存一覽) -->
<div x-show="showInventoryPanel" class="fixed inset-0 z-[100] overflow-hidden" style="display: none;"
aria-labelledby="inventory-panel-title" role="dialog" aria-modal="true">
<!-- Background backdrop -->
<div x-show="showInventoryPanel" x-transition:enter="ease-in-out duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in-out duration-300" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
@click="showInventoryPanel = false">
</div>
<div class="fixed inset-y-0 right-0 max-w-full flex">
<!-- Sliding panel -->
<div x-show="showInventoryPanel"
x-transition:enter="transform transition ease-in-out duration-500 sm:duration-700"
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition ease-in-out duration-500 sm:duration-700"
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
class="w-screen max-w-4xl">
<div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl">
<!-- Header -->
<div
class="px-5 py-6 sm:px-8 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<h2 id="inventory-panel-title"
class="text-xl sm:text-2xl font-black text-slate-800 dark:text-white font-display flex items-center gap-2 sm:gap-3">
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-cyan-500 flex-shrink-0"
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<span class="truncate">{{ __('Stock & Expiry Overview') }}</span>
</h2>
<div
class="mt-2 flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-[10px] sm:text-sm text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest overflow-hidden">
<span x-text="currentMachineSn"
class="font-mono text-cyan-600 dark:text-cyan-400 truncate"></span>
<span class="hidden sm:inline opacity-50"></span>
<span x-text="currentMachineName" class="truncate"></span>
</div>
</div>
<div class="flex-shrink-0 h-7 flex items-center">
<button type="button" @click="showInventoryPanel = false"
class="bg-white dark:bg-slate-800 rounded-full p-2 text-slate-400 hover:text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 transition duration-300 shadow-sm border border-slate-200 dark:border-slate-700">
<span class="sr-only">{{ __('Close Panel') }}</span>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- 統計摘要 -->
<div class="mt-6 flex items-center gap-4">
<div
class="px-5 py-3 rounded-2xl bg-white dark:bg-slate-800/50 flex flex-col items-center min-w-[100px] border border-slate-100 dark:border-slate-800/50">
<span class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{{
__('Total Slots') }}</span>
<span class="text-2xl font-black text-slate-700 dark:text-slate-200"
x-text="inventorySlots.length"></span>
</div>
<div
class="px-5 py-3 rounded-2xl bg-rose-500/5 border border-rose-500/10 flex flex-col items-center min-w-[100px]">
<span class="text-[9px] font-black text-rose-500 uppercase tracking-widest mb-0.5">{{
__('Low Stock') }}</span>
<span class="text-2xl font-black text-rose-600"
x-text="inventorySlots.filter(s => s != null && s.stock <= 5).length"></span>
</div>
<div
class="px-5 py-3 rounded-2xl bg-amber-500/5 border border-amber-500/10 flex flex-col items-center min-w-[100px]">
<span class="text-[9px] font-black text-amber-500 uppercase tracking-widest mb-0.5">{{
__('Expiring') }}</span>
<span class="text-2xl font-black text-amber-600"
x-text="inventorySlots.filter(s => { if (!s || !s.expiry_date) return false; const diff = Math.round((new Date(s.expiry_date) - new Date()) / 86400000); return diff >= 0 && diff <= 7; }).length"></span>
</div>
</div>
</div><!-- /Header -->
<!-- Body / Cabinet Grid -->
<div class="flex-1 overflow-y-auto p-6 sm:p-8">
<div class="relative min-h-[400px]">
<!-- Loading State -->
<div x-show="inventoryLoading"
class="absolute inset-0 bg-white/50 dark:bg-slate-900/50 backdrop-blur-[1px] flex items-center justify-center z-20">
<div class="flex flex-col items-center gap-3">
<div
class="w-8 h-8 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin">
</div>
<span class="text-xs font-black text-slate-400 uppercase tracking-widest">{{
__('Loading...') }}</span>
</div>
</div>
<!-- Status Legend -->
<div class="flex items-center gap-6 mb-6" x-show="!inventoryLoading">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-rose-500 shadow-lg shadow-rose-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{
__('Expired') }}</span>
</div>
<div class="flex items-center gap-2">
<span
class="w-3 h-3 rounded-full bg-amber-500 shadow-lg shadow-amber-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{
__('Warning') }}</span>
</div>
<div class="flex items-center gap-2">
<span
class="w-3 h-3 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{
__('Normal') }}</span>
</div>
</div>
<!-- Slots Grid (唯讀) -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5"
x-show="!inventoryLoading">
<template x-for="slot in inventorySlots" :key="slot.id">
<div :class="getSlotColorClass(slot)"
class="min-h-[260px] rounded-[2rem] p-5 flex flex-col items-center justify-center border-2 transition-all duration-300 relative">
<!-- Slot Header -->
<div
class="absolute top-3.5 left-4 right-4 flex justify-between items-center z-10">
<div
class="px-2.5 py-1 rounded-xl bg-slate-900/10 dark:bg-white/10 backdrop-blur-md border border-slate-900/5 dark:border-white/10 flex-shrink-0">
<span
class="text-xs font-black uppercase tracking-tighter text-slate-800 dark:text-white"
x-text="slot.slot_no"></span>
</div>
<template x-if="slot.stock <= 2">
<div
class="px-2 py-1 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 animate-pulse whitespace-nowrap select-none">
{{ __('Low') }}
</div>
</template>
</div>
<!-- Product Image -->
<div class="relative w-16 h-16 mb-3 mt-2">
<div
class="absolute inset-0 rounded-2xl bg-white/20 dark:bg-slate-900/40 backdrop-blur-xl border border-white/30 dark:border-white/5 shadow-inner overflow-hidden">
<template x-if="slot.product && slot.product.image_url">
<img :src="slot.product.image_url"
class="w-full h-full object-cover">
</template>
<template x-if="!slot.product || !slot.product.image_url">
<div class="w-full h-full flex items-center justify-center">
<svg class="w-7 h-7 opacity-20" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2.5"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
</template>
</div>
</div>
<!-- Slot Info -->
<div class="text-center w-full space-y-2">
<template x-if="slot.product">
<div class="text-sm font-black truncate w-full opacity-90 tracking-tight"
x-text="slot.product.name"></div>
</template>
<template x-if="!slot.product">
<div
class="text-sm font-bold text-slate-300 dark:text-slate-600 tracking-tight">
{{ __('Empty') }}</div>
</template>
<div class="space-y-2">
<!-- Stock Level -->
<div class="flex items-baseline justify-center gap-1">
<span class="text-xl font-black tracking-tighter leading-none"
x-text="slot.stock"></span>
<span class="text-xs font-black opacity-30">/</span>
<span class="text-sm font-bold opacity-50"
x-text="slot.max_stock || 10"></span>
</div>
<!-- Expiry Date -->
<div class="text-sm font-black tracking-tight leading-none opacity-80"
x-text="slot.expiry_date ? slot.expiry_date.replace(/-/g, '/') : '----/--/--'">
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Empty state -->
<template x-if="inventorySlots.length === 0 && !inventoryLoading">
<div class="px-6 py-20 text-center">
<div class="flex flex-col items-center">
<div
class="p-4 rounded-full bg-slate-50 dark:bg-slate-800/50 mb-4 border border-slate-100 dark:border-slate-800/50">
<svg class="w-8 h-8 text-slate-300 dark:text-slate-600" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.5">
<path
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<span
class="text-[11px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{
__('No slot data available') }}</span>
</div>
</div>
</template>
</div>
</div><!-- /Body -->
</div>
</div><!-- /Sliding panel -->
</div>
</div><!-- /Inventory Offcanvas -->
@endsection @endsection

View File

@@ -61,7 +61,10 @@
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg> </svg>
</span> </span>
<input type="text" name="search" value="{{ request('search') }}" class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input" placeholder="{{ __('Search roles...') }}"> <input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search roles...') }}"
@keydown.enter="$el.form.submit()">
</div> </div>
@if(auth()->user()->isSystemAdmin()) @if(auth()->user()->isSystemAdmin())
@@ -79,6 +82,7 @@
</div> </div>
@endif @endif
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}"> <input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
<button type="submit" class="hidden"></button>
</form> </form>
</div> </div>

View File

@@ -166,7 +166,12 @@
<a href="#{{ $api['slug'] }}" class="luxury-nav-link" <a href="#{{ $api['slug'] }}" class="luxury-nav-link"
:class="{ 'active': activeSection === '{{ $api['slug'] }}' }" :class="{ 'active': activeSection === '{{ $api['slug'] }}' }"
@click="activeSection = '{{ $api['slug'] }}'"> @click="activeSection = '{{ $api['slug'] }}'">
<span>{{ $api['name'] }}</span> <div class="flex items-center gap-2 overflow-hidden w-full">
<span class="flex-shrink-0 text-[10px] w-10 text-center font-black px-1.5 py-0.5 rounded-md uppercase tracking-tighter {{ $api['method'] === 'GET' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' : ($api['method'] === 'POST' ? 'bg-cyan-50 text-cyan-600 border border-cyan-200' : 'bg-amber-50 text-amber-600 border border-amber-200') }}">
{{ $api['method'] }}
</span>
<span class="truncate">{{ $api['name'] }}</span>
</div>
</a> </a>
@endforeach @endforeach
@endforeach @endforeach
@@ -187,7 +192,12 @@
<div id="{{ $api['slug'] }}" class="mb-16 animate-luxury-in" x-intersect="activeSection = '{{ $api['slug'] }}'"> <div id="{{ $api['slug'] }}" class="mb-16 animate-luxury-in" x-intersect="activeSection = '{{ $api['slug'] }}'">
<div class="mb-4"></div> <div class="mb-4"></div>
<h3 class="font-display text-3xl font-black text-slate-900 mb-6 tracking-tight">{{ $api['name'] }}</h3> <div class="flex items-center gap-4 mb-6">
<span class="px-3 py-1 text-sm font-black rounded-lg uppercase tracking-widest {{ $api['method'] === 'GET' ? 'bg-emerald-100 text-emerald-700' : ($api['method'] === 'POST' ? 'bg-cyan-100 text-cyan-700' : 'bg-amber-100 text-amber-700') }}">
{{ $api['method'] }}
</span>
<h3 class="font-display text-3xl font-black text-slate-900 tracking-tight">{{ $api['name'] }}</h3>
</div>
<p class="text-slate-600 mb-8 text-lg font-medium">{{ $api['description'] }}</p> <p class="text-slate-600 mb-8 text-lg font-medium">{{ $api['description'] }}</p>
<!-- Headers & URL --> <!-- Headers & URL -->
@@ -264,7 +274,7 @@
<span <span
class="text-[11px] font-black text-slate-400 uppercase tracking-tight">範例:</span> class="text-[11px] font-black text-slate-400 uppercase tracking-tight">範例:</span>
<code <code
class="text-sm text-cyan-600 font-bold">{{ is_array($param['example']) ? json_encode($param['example']) : $param['example'] }}</code> class="text-sm text-cyan-600 font-bold">{{ is_array($param['example']) ? json_encode($param['example'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $param['example'] }}</code>
</div> </div>
@endif @endif
</td> </td>
@@ -280,7 +290,7 @@
<!-- Request Example --> <!-- Request Example -->
<div> <div>
<h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-6">請求範例 (Request Body)</h4> <h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-6">請求範例 (Request Body)</h4>
<pre><code>{{ json_encode($api['request'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</code></pre> <pre><code>{{ json_encode($api['request'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) }}</code></pre>
</div> </div>
<!-- Response Examples --> <!-- Response Examples -->
@@ -310,7 +320,7 @@
@if(isset($param['example'])) @if(isset($param['example']))
<div class="mt-2 flex items-center gap-2"> <div class="mt-2 flex items-center gap-2">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-tight">範例:</span> <span class="text-[10px] font-black text-slate-400 uppercase tracking-tight">範例:</span>
<code class="text-xs text-cyan-600 font-bold bg-cyan-50/50 px-2 py-0.5 rounded">{{ $param['example'] }}</code> <code class="text-xs text-cyan-600 font-bold bg-cyan-50/50 px-2 py-0.5 rounded">{{ is_array($param['example']) ? json_encode($param['example'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $param['example'] }}</code>
</div> </div>
@endif @endif
</td> </td>
@@ -322,7 +332,7 @@
@endif @endif
<h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">回應範例 (Response Body)</h4> <h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">回應範例 (Response Body)</h4>
<pre><code>{{ json_encode($api['response'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</code></pre> <pre><code>{{ json_encode($api['response'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) }}</code></pre>
</div> </div>
@if(isset($api['notes'])) @if(isset($api['notes']))

View File

@@ -51,6 +51,9 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
// 機台管理員 B000 登入驗證 (由於此階段機台未帶 Token 無法通過 iot.auth) // 機台管理員 B000 登入驗證 (由於此階段機台未帶 Token 無法通過 iot.auth)
Route::prefix('app')->group(function () { Route::prefix('app')->group(function () {
Route::post('admin/login/B000', [\App\Http\Controllers\Api\V1\App\MachineAuthController::class, 'loginB000'])->middleware('throttle:30,1'); Route::post('admin/login/B000', [\App\Http\Controllers\Api\V1\App\MachineAuthController::class, 'loginB000'])->middleware('throttle:30,1');
// 機台啟動引導與參數下載 (需人員登入 Token)
Route::middleware('auth:sanctum')->post('machine/setting/B014', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getSettings']);
}); });
Route::prefix('app')->middleware(['iot.auth', 'throttle:100,1'])->group(function () { Route::prefix('app')->middleware(['iot.auth', 'throttle:100,1'])->group(function () {
@@ -61,6 +64,16 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
Route::post('machine/coins/B220', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncCoinInventory']); Route::post('machine/coins/B220', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncCoinInventory']);
Route::post('machine/member/verify/B650', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyMember']); Route::post('machine/member/verify/B650', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyMember']);
// 廣告與貨道清單 (B005, B009, B012)
Route::get('machine/ad/B005', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getAdvertisements']);
Route::put('products/supplementary/B009', [App\Http\Controllers\Api\V1\App\MachineController::class, 'reportSlotList']);
// 統一商品主檔 API (B012 整合版)
Route::match(['get', 'patch'], 'machine/products/B012', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getProducts']);
// 機台故障與異常上報 (B013)
Route::post('machine/error/B013', [App\Http\Controllers\Api\V1\App\MachineController::class, 'reportError']);
// 交易、發票與出貨 (B600, B601, B602) // 交易、發票與出貨 (B600, B601, B602)
Route::post('machine/restock/B018', [App\Http\Controllers\Api\V1\App\MachineController::class, 'recordRestock']); Route::post('machine/restock/B018', [App\Http\Controllers\Api\V1\App\MachineController::class, 'recordRestock']);
Route::post('B600', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'store']); Route::post('B600', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'store']);