Compare commits
62 Commits
eb73def5f8
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| f49938d1a7 | |||
| 2702e5a655 | |||
| 6382709b90 | |||
| 66f7c1ffb8 | |||
| 32fa28dc0f | |||
| daf8b1ebcc | |||
| 8f008ffb61 | |||
| 729890d7c7 | |||
| ad256d3d3b | |||
| 5415b14a53 | |||
| c97776892e | |||
| a599b14df1 | |||
| c343df34ee | |||
| 253ae8afd4 | |||
| f2147ae6c4 | |||
| b60afc3abe | |||
| bbdc5bad9f | |||
| 08b6c60d2e | |||
| e085058d63 | |||
| e7ad7e3dc3 | |||
| 3dbb394862 | |||
| 969e4df629 | |||
| 7c47ad67fa | |||
| 953d6a41f3 | |||
| 2e49129d77 | |||
| 08fc86d3f8 | |||
| e27eee78f5 | |||
| 759fae4380 | |||
| 54d62c5378 | |||
| d14eda7d69 | |||
| 9bbfaa39e6 | |||
| 2c9dc793d7 | |||
| f3b2c3e018 | |||
| 44ef355c54 | |||
| ea0333d77e | |||
| e780e195e2 | |||
| fdd3589d7b | |||
| c875ab7d29 | |||
| 740eaa30b7 | |||
| 8ec5473ec7 | |||
| ac51027dda | |||
| 17b5c1a316 | |||
| 7883a755d2 | |||
| 03f8fdb654 | |||
| f60e5a9c72 | |||
| 19076c363c | |||
| 675e285e8c | |||
| b7ff8ac01c | |||
| c015666f87 | |||
| 3629caebd0 | |||
| 37ef6f1c10 | |||
| 3d24ddff5a | |||
| 9f3a90b2b0 | |||
| 2467d9db7a | |||
| f98d059bc3 | |||
| 7209f9ea98 | |||
| 5c55553905 | |||
| 87ef247a48 | |||
| 38770b080b | |||
| 72812f9b0b | |||
| d2cefe3f39 | |||
| 6588dcd7f7 |
@@ -56,25 +56,32 @@ trigger: always_on
|
||||
### 4.2 終端類 (IoT / Machine) — 須嚴格遵守 PDF 規格
|
||||
* **API 識別碼 (workid)**: URL 中的 `{workid}` 參數固定為該 API 的功能代碼 (如 `B010`, `B017`, `B600`),不隨機台改變。
|
||||
* **機台識別方式**:
|
||||
1. **Header**: 透過 `Authorization: Bearer <api_token>` 識別。
|
||||
2. **Request Body**: 透過 `machine` 或 `serial_no` 等欄位識別具體機台。
|
||||
1. **Header (推薦)**: 透過 `Authorization: Bearer <api_token>` 識別。針對 B017 等端點,雲端將自動關聯對應機台,**不需**額外帶入機台識別參數。
|
||||
2. **Request Body (相容/特定模式)**: 透過 `machine` 或 `serial_no` 等欄位識別。主要用於 B000 登入或尚未取得 Token 的引導階段 (如 B014)。
|
||||
* **主要 Endpoint 範例**:
|
||||
* **心跳上報 (B010)**: `POST /api/app/machine/status/B010`
|
||||
* **交易回傳 (B600)**: `POST /api/app/B600` (Body 欄位 `req2` 為機台編號)
|
||||
* **貨道庫存 (B017)**: `POST /api/app/machine/reload_msg/B017`
|
||||
* **貨道庫存 (B017)**: `GET /api/app/machine/reload_msg/B017`
|
||||
* **遠端出貨 (B055)**: `POST /api/app/machine/dispense/B055`
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 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 在背景完成數據持久化。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,19 +6,28 @@ trigger: always_on
|
||||
|
||||
## 1. 專案概述
|
||||
* **目標**:打造一個強大且穩定的智能販賣機後台管理系統(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 為主體。
|
||||
|
||||
## 2. 技術棧 (Tech Stack)
|
||||
|
||||
### 2.1 後端核心 (Laravel)
|
||||
* **後端框架**: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
|
||||
* **前端互動 (JS)**:Alpine.js (專注於行為,不負責渲染)
|
||||
* **介面與樣式 (CSS)**:Tailwind CSS + Preline UI (直接寫作於 Blade 模板中)。
|
||||
* **重要規範**:Preline UI 僅作為「原子組件」與「JS 互動邏輯」的參考庫。整體的「佈局」與「美學」必須嚴格遵守「極簡奢華風 UI 實作規範 (SKILL.md)」。
|
||||
* **前端建置工具**:Vite
|
||||
* **資料庫**:MySQL 8.0
|
||||
* **開發環境**:Laravel Sail (Docker / WSL2)
|
||||
|
||||
## 3. 目錄結構與慣例
|
||||
|
||||
@@ -29,31 +38,111 @@ trigger: always_on
|
||||
* **Routes**:`routes/web.php` 用於後台管理介面;`routes/api.php` 提供外部或機台調用介面 (需 V1 版本化)。
|
||||
* **Services** (建議):`app/Services/{Domain}/`,將商業邏輯與資料異動封裝於 Service 中。
|
||||
* **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)
|
||||
* **Views (頁面)**:位於 `resources/views/`。通常依功能建立資料夾(如 `resources/views/admin/machines/index.blade.php`)。
|
||||
* **Layouts (版面)**:位於 `resources/views/layouts/`。定義全站的共用版面結構(如 header, sidebar, footer)。
|
||||
* **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`)
|
||||
* Models: `PascalCase.php` (例如 `Machine.php`)
|
||||
* Blade Views: `kebab-case.blade.php` 或按資源名稱 (例如 `index.blade.php`, `create.blade.php`)
|
||||
* Routes uri: `kebab-case` (例如 `/machine-logs`)
|
||||
* Go 檔案: `snake_case.go` (例如 `heartbeat_handler.go`)
|
||||
* **回傳格式**:
|
||||
* Web 路由:回傳 `view()`,表單驗證失敗時直接使用 Laravel 內建的 redirect with errors。
|
||||
* API 路由:回傳標準 JSON 格式的 `JsonResponse`。
|
||||
|
||||
## 5. UI 與前端開發指南
|
||||
## 6. UI 與前端開發指南
|
||||
* **樣式撰寫**:全面使用 Tailwind CSS utility classes,**避免撰寫自訂 CSS**(除非少數特定動畫或覆寫)。
|
||||
* **UI 元件庫**:遵循 **Preline UI** 的類別與 HTML 結構進行開發。
|
||||
* **前端腳本**:
|
||||
* 優先使用 **Alpine.js** (`x-data`, `x-show`, `@click` 等) 在 HTML 標籤內完成簡單的 DOM 狀態切換與互動邏輯。
|
||||
* 避免在 Blade 內撰寫冗長的 `<script>` Vanilla JS;若邏輯過於複雜,可將 Alpine state 獨立成 js 檔案再於 Vite 引入,但原則上保持輕量。
|
||||
|
||||
## 6. 多語系 I18n 規範 (Multi-language Standards)
|
||||
## 7. 多語系 I18n 規範 (Multi-language Standards)
|
||||
* **視圖開發**:所有使用者可見的文字、按鈕、提示訊息,必須使用 Laravel 的 `@lang('key')` 或 `__('key')` 函式包裹。
|
||||
* **語系 Key 命名**:語系 Key 必須採用 **英文原始詞彙 (English phrases)** 作為 Key 名稱為原則,以提高代碼可讀性並作為預設回退(除非該字串過長,才建議使用點號分隔的 key)。
|
||||
* 範例:使用 `__('Account Settings')`。
|
||||
@@ -61,7 +150,7 @@ trigger: always_on
|
||||
* 主語系檔案位於 `lang/` 目錄。
|
||||
* 開發新功能時,必須同步更新以下三個 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` 補上翻譯。
|
||||
* 生成 UI 區塊時,必須優先參考與產生 **Preline UI** 風格與結構的標記語法。
|
||||
* 開發新功能時,請建立標準的 Controller 搭配對應的 `resources/views/.../` 目錄。
|
||||
* **【Go Gateway 開發】** 修改 `mqtt-gateway/` 內的 Go 程式碼時,嚴禁加入商業邏輯。Go 僅負責訊息接收、格式轉換與 Redis 轉發。
|
||||
|
||||
## 8. 運行機制 (Docker / Sail)
|
||||
## 9. 運行機制 (Docker / 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`
|
||||
* **執行 Artisan 指令**:`./vendor/bin/sail artisan route:list`
|
||||
* **執行 Composer**:`./vendor/bin/sail composer install`
|
||||
* **執行 Node/NPM**:`./vendor/bin/sail npm run dev`
|
||||
|
||||
## 8. 部署與查修環境 (CI/CD)
|
||||
## 10. 部署與查修環境 (CI/CD)
|
||||
* **自動化部署**:專案具備基於 Gitea Actions 的 CI/CD 自動化部署流程 (`.gitea/workflows/`)。
|
||||
* **Demo 環境 (對應 `demo` 分支)**:
|
||||
* 透過 `deploy-demo.yaml`,合併或推送到 `demo` 分支會自動部署至 `demo-cloud.taiwan-star.com.tw`。
|
||||
* 登入伺服器查修:`ssh gitea_work`,路徑為 `/var/www/star-cloud-demo`。
|
||||
|
||||
## 9. 瀏覽器測試規範 (Browser Testing)
|
||||
## 11. 瀏覽器測試規範 (Browser Testing)
|
||||
當需要進行瀏覽器自動化測試或手動驗證時,必須遵守以下連線資訊:
|
||||
|
||||
* **本地測試網址**:`http://localhost:8090/` (注意:非 8000 或 8080)
|
||||
|
||||
@@ -24,6 +24,11 @@ trigger: always_on
|
||||
- 建立新資源時,必須在背景強制綁定 `company_id`,禁止由前端傳參決定。
|
||||
- 範例:`$model->company_id = Auth::user()->company_id;`
|
||||
|
||||
### 1.4 角色清單隔離 (Role List Isolation)
|
||||
- 租戶管理員 (Tenant Admin) 只能管理隸屬於其公司下的角色。
|
||||
- **嚴禁使用**包含 `NULL` 的 `forCompany` 廣義作用域來展示管理清單。
|
||||
- 查詢時必須嚴格使用 `where('company_id', auth()->user()->company_id)` 隔離系統 Super Admin 或 角色範本。
|
||||
|
||||
---
|
||||
|
||||
## 2. 權限開發規範 (spatie/laravel-permission)
|
||||
@@ -36,6 +41,12 @@ trigger: always_on
|
||||
- 權限名稱應遵循 `[module].[action]` 格式(例如 `machine.view`, `machine.edit`)。
|
||||
- 所有租戶共用相同的權限定義。
|
||||
|
||||
### 2.3 權限遞迴約束 (Privilege Delegation Constraint)
|
||||
為防止權限提升 (Privilege Escalation):
|
||||
- **權限子集驗證**:管理員僅能指派其**自身持有**之權限給其他角色或帳號。
|
||||
- **Controller 實作**:在 `store` 或 `update` 時,必須比對傳入的權限集合是否為操作者 `getPermissionNames()` 的子集。
|
||||
- **UI 過濾**:權限分配介面應基於當前使用者權限清單進行動態過濾展示。
|
||||
|
||||
---
|
||||
|
||||
## 3. 介面安全 (UI/Blade)
|
||||
@@ -61,10 +72,20 @@ trigger: always_on
|
||||
|
||||
### 5.1 初始角色建立
|
||||
當系統管理員為新客戶(該租戶尚未有任何角色)建立第一個帳號時,應遵循以下邏輯:
|
||||
1. **選取範本**:從系統預設的「全域角色範本」(`company_id = null` 且 `is_system = 0`)中選取一個作為基礎。
|
||||
1. **選取範本**:從系統預設的「全域角色範本」(`company_id = null` 且 `is_system = 1`)中選取一個作為基礎,但必須排除「超級管理員 (`super-admin`)」。
|
||||
2. **自動克隆**:系統會將該範本的權限內容複製一份至該租戶下。
|
||||
3. **統一命名**:克隆後的角色名稱在該租戶公司內應統一命名為**「管理員」**。
|
||||
4. **帳號綁定**:該新客戶帳號將被指派至此新建立的「管理員」角色。
|
||||
|
||||
### 5.2 角色權限維護
|
||||
- 初始建立後,該租戶的「管理員」角色即成為獨立資源,可由具有權限的帳號進行細部調整。
|
||||
|
||||
### 5.3 機台授權原則 (Machine Authorization) [CRITICAL]
|
||||
- **以帳號為準**:機台授權是基於「帳號 (User)」而非「角色 (Role)」。
|
||||
- **授權層級**:
|
||||
1. **系統管理員 (isSystemAdmin = true)**:具備全系統所有機台之完整權限。
|
||||
2. **租戶/公司帳號 (含管理員)**:僅能存取由「系統管理員」明確授權給該帳號的機台(透過 `machine_user` 關聯)。
|
||||
3. **子帳號**:僅能存取由其「公司管理員」所授權的機台子集。
|
||||
- **實作要求**:
|
||||
- `Machine` Model 的全域過濾器**不得**對「管理員 (Tenant Admin)」角色進行例外排除。
|
||||
- 所有的機台存取必須嚴格比對 `machine_user` 表,除非操作者為「系統管理員 (Super Admin)」。
|
||||
|
||||
@@ -14,8 +14,9 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
||||
|
||||
| 觸發詞 / 情境 | 對應 Skill | 路徑 |
|
||||
|---|---|---|
|
||||
| 機台通訊, IoT, 日誌上報, Log Ingestion, 異步隊列, Queue, Heartbeat, 心跳發報 | **IoT 通訊與高併發處理規範** | `.agents/skills/iot-communication/SKILL.md` |
|
||||
| 介面, UI, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/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` |
|
||||
| 介面, 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` |
|
||||
| RBAC, 權限, 角色, 租戶, Tenant, Company, Access Control, 多租戶, 權限控管 | **多租戶與權限架構實作規範** | `.agents/rules/rbac-rules.md` |
|
||||
|
||||
@@ -41,4 +42,4 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
||||
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
|
||||
必須讀取:
|
||||
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
|
||||
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用
|
||||
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用
|
||||
356
.agents/skills/api-technical-specs/SKILL.md
Normal file
356
.agents/skills/api-technical-specs/SKILL.md
Normal file
@@ -0,0 +1,356 @@
|
||||
---
|
||||
name: API 技術規格與通訊協議規範
|
||||
description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與後端 (Cloud) 通訊的 API 細節、參數命名規則、狀態代碼對照與認證機制,作為系統開發的唯一規格來源。
|
||||
---
|
||||
|
||||
# API 技術規格與通訊協議規範 (API Technical Specs)
|
||||
|
||||
本文件集中定義所有機台與雲端通訊的 API 規格,確保硬體端與軟體端在資料交換格式與業務定義上保持完全一致。
|
||||
|
||||
## 1. 核心命名原則
|
||||
- **語意化優先**:捨棄舊版 M_ 前綴,統一使用 snake_case (如 firmware_version)。
|
||||
- **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。
|
||||
|
||||
## 2. 身份認證 (Authentication)
|
||||
本系統採用兩階段認證模式:
|
||||
|
||||
### 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>`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 機台核心 API (IoT Endpoints)
|
||||
|
||||
### 3.1 B000: 機台本地管理員同步登入
|
||||
用於機台 Android 端維護人員登入與進入設定頁。此 API 無狀態,且為例外不強制檢查 Bearer Token 的端點。
|
||||
|
||||
- **URL**: POST /api/v1/app/admin/login/B000
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
|
||||
| Su_Account | String | 是 | 系統管理員或公司管理員帳號 | admin |
|
||||
| Su_Password | String | 是 | 密碼 | password123 |
|
||||
| ip | String | 否 | 用戶端 IP (相容舊版) | 192.168.1.100 |
|
||||
| type | String | 否 | 裝置類型代碼 (相容舊版) | 2 |
|
||||
|
||||
- **Response Body:**
|
||||
> [!IMPORTANT]
|
||||
> 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 Success。
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| message | String | 驗證結果 (Success 或 Failed) | Success |
|
||||
| token | String | **臨時身份認證 Token** (用於 B014) | 1|abcdefg... |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 B005: 廣告清單同步
|
||||
用於機台端獲取目前應播放的廣告檔案 URL 清單。
|
||||
|
||||
- **URL**: GET /api/v1/app/machine/ad/B005
|
||||
- **Request Body:** 無 (GET 請求)
|
||||
|
||||
- **Response Body:**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 請求是否成功 | true |
|
||||
| code | Integer | 內部業務狀態碼 | 200 |
|
||||
| data | Array | 廣告物件陣列 | [{"t070v04": "https://..."}] |
|
||||
|
||||
**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 代碼對照表
|
||||
|
||||
**頁面代碼 (current_page):**
|
||||
- 0: 離線 / 1: 主頁面 / 2: 販賣頁 / 3: 管理頁
|
||||
- 4: 補貨頁 / 5: 教學頁 / 6: 購買中 / 7: 鎖定頁
|
||||
- 60: 出貨成功 / 61: 貨道測試 / 62: 付款選擇
|
||||
- 63: 等待付款 / 64: 出貨 / 65: 收據簽單
|
||||
- 66: 通行碼 / 67: 取貨碼 / 68: 訊息顯示
|
||||
- 69: 取消購買 / 610: 購買結束 / 611: 來店禮
|
||||
- 612: 出貨失敗
|
||||
|
||||
**雲端指令代碼 (status):**
|
||||
- 49: reload B017 (貨道同步)
|
||||
- 51: reboot (重啟系統)
|
||||
- 60: reboot card machine (刷卡機重啟)
|
||||
- 61: checkout (觸發結帳)
|
||||
- 70: unlock (解鎖)
|
||||
- 71: lock (鎖定)
|
||||
- 85: reload B0552 (遠端出貨)
|
||||
|
||||
---
|
||||
|
||||
### 3.5 B012: 商品配置與商品主檔同步 (Unified Sync)
|
||||
用於機台端獲取目前所有可販售商品的詳細配置。App 端應依據呼叫的方法決定數據處理方式。
|
||||
|
||||
- **URL**: GET|PATCH /api/v1/app/machine/products/B012
|
||||
- **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**: GET /api/v1/app/machine/setting/B014
|
||||
- **Authentication**: **User Token** (Sanctum Header)
|
||||
- **Request Body:** 無 (由 Query String 帶入 `machine` 參數)
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| 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 校驗。只有當前登入的人員具備該機台管理權限時,後端才允許發放資料。
|
||||
|
||||
---
|
||||
|
||||
### 3.8 B017: 貨道庫存同步 (Slot Synchronization)
|
||||
用於機台端獲取目前所有貨道的最新庫存、效期與狀態。通常由 B010 回應 `status: 49` 觸發。
|
||||
|
||||
- **URL**: GET /api/v1/app/machine/reload_msg/B017
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Request Body:** 無 (由 Token 自動識別機台)
|
||||
|
||||
- **Response Body:**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 請求是否成功 | true |
|
||||
| code | Integer | 200 | 200 |
|
||||
| data | Array | 貨道數據陣列 (依 slot_no 排序) | 見下表 |
|
||||
|
||||
**data 陣列內部欄位:**
|
||||
|
||||
| 欄位 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| tid | String | 貨道編號 | "1" |
|
||||
| num | Integer | 當前庫存數量 | 10 |
|
||||
| expiry_date | String | 商品效期 | "2026-12-31" |
|
||||
| batch_no | String | 批號 | "B202604" |
|
||||
| product_id | Integer | 商品 ID | 1 |
|
||||
| capacity | Integer | 貨道容量上限 | 15 |
|
||||
| status | String | 貨道狀態 ("1": 啟用, "0": 停用) | "1" |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **同步機制**:B017 為全量同步。App 收到回應後應更新本地資料庫對應貨道的數值。成功請求後,雲端會自動將相關的 `reload_stock` 指令標記為 `success`。
|
||||
|
||||
---
|
||||
|
||||
### 3.9 B024: 取貨碼/通行碼驗證與消耗回報 (Access Code Verify & Report)
|
||||
用於處理機台端的代碼取貨流程。包含驗證代碼有效性(驗證階段)與確認出貨完成後的狀態消耗(回報階段)。
|
||||
|
||||
- **URL**: POST|PUT /api/v1/app/sell/access-code/B024
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Request Body (POST - 驗證階段):**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| passCode | String | 是 | 使用者輸入的取貨碼/通行碼 | "12345678" |
|
||||
|
||||
- **Response Body (POST - 驗證成功 200):**
|
||||
雲端將回傳該碼對應的權限或待出貨商品資訊。
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| res1 | String | 該代碼在雲端的關聯 ID | "99" |
|
||||
| res2 | String | 操作模式 (1: 出貨, 2: 僅驗證) | "1" |
|
||||
| res3 | String | 預計出貨商品 ID | "5" |
|
||||
| res4 | String | 折扣金額或活動標籤 | "0.0" |
|
||||
|
||||
- **Request Body (PUT - 回報階段):**
|
||||
當機台端確認實體出貨成功後,必須發送此請求以耗刷該代碼。
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| accessCodeId | String | 是 | 驗證階段取得的 res1 (ID) | "99" |
|
||||
| status | String | 是 | 出貨結果 (1: 成功, 0: 失敗) | "1" |
|
||||
|
||||
---
|
||||
|
||||
### 3.10 B027: 贈品碼/優惠券驗證與消耗回報 (Free Gift Verify & Report)
|
||||
用於處理行銷贈品券、0 元購活動或特定的優惠券核銷流程。
|
||||
|
||||
- **URL**: POST|PUT /api/v1/app/sell/free-gift/B027
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **運作模式**:
|
||||
- **POST**: 提交 `passCode` 驗證該贈品券是否有效。成功後雲端會回傳對應活動 ID 與商品配置。
|
||||
- **PUT**: 當該贈品(0 元商品)出貨完成後,向雲端回報消耗,確保優惠券不會重複核銷。
|
||||
|
||||
> [!NOTE]
|
||||
> B027 與 B024 的邏輯具備高度對稱性,區別在於 B027 通常綁定的是特定的行銷活動 (Campaign) 與 0 元出貨邏輯。
|
||||
|
||||
---
|
||||
|
||||
### 3.11 B055: 遠端指令出貨控制 (Remote Dispense / Force Open)
|
||||
用於遠端手動驅動機台出貨。通常用於補償使用者、測試機台或客服協助開門的情景。
|
||||
|
||||
- **URL**: POST|PUT /api/v1/app/machine/dispense/B055
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **運作模式**:
|
||||
- **POST (查詢)**: 當 B010 收到 `status: 85` 時呼叫。雲端會回傳待執行的貨道編號與指令 ID。
|
||||
- **PUT (回報)**: 實體出貨完成後回報結果,以便雲端將該指令標記為「已執行」。
|
||||
|
||||
- **Request Body (PUT - 回報階段):**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| id | String | 是 | 雲端下發的指令 ID | "20260414001" |
|
||||
| type | String | 是 | 出貨類型代碼 (通常為 0) | "0" |
|
||||
| stock | String | 是 | 出貨後的貨道剩餘數量 | "9" |
|
||||
@@ -9,13 +9,25 @@ description: 規範智能販賣機與 Cloud 平台間的高頻通訊處理流程
|
||||
|
||||
## 1. 處理管線 (Processing Pipeline)
|
||||
|
||||
所有來自機台的非即時性資料(日誌、心跳、狀態上報)必須遵循以下 pipeline:
|
||||
所有來自機台的非即時性資料(日誌、心跳、狀態上報)必須遵循以下 pipeline。依據通訊協議不同,進入點有兩條路徑:
|
||||
|
||||
### 1.1 HTTP 管線 (低頻/大檔操作)
|
||||
適用於 B000, B009, B012, B014, B017 等低頻、同步需求或大資料量的端點:
|
||||
1. **API Controller (接收層)**:驗證 Request 合法性,隨即分派 (Dispatch) 任務至 Queue,並回傳 `202 Accepted`。
|
||||
2. **Job (異步層)**:由背景 Worker 讀取隊列任務,呼叫對應 Service 處理。
|
||||
3. **Service (邏輯層)**:封裝商業邏輯,更新資料庫。
|
||||
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]
|
||||
> **嚴禁**在 API Controller 直接進行資料庫寫入操作(針對機台發訊端點)。
|
||||
|
||||
@@ -53,11 +65,34 @@ public function handle(MachineService $service): void
|
||||
|
||||
## 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 導致的連線暴衝。
|
||||
|
||||
### 4.2 MQTT 端點
|
||||
- 限速由 **EMQX Broker** 的 Rate Limiting 功能負責(非 Laravel Middleware)。
|
||||
- Go Gateway 層可額外實作簡易的 Token Bucket,當某台機台每秒超過閾值時丟棄訊息並記錄 Warning Log。
|
||||
|
||||
## 5. 檢核項目 (Checklist)
|
||||
- [ ] 是否使用了 `ApiResponse` Trait?
|
||||
- [ ] 業務邏輯是否已封裝至 `App\Services`?
|
||||
- [ ] 是否使用了 Redis Queue 進行非同步處理?
|
||||
- [ ] 是否在 API 層級進行了基礎的參數驗證?
|
||||
|
||||
## 6. API 規格定義 (API Specifications)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **規格分離原則**:本技能僅規範「通訊處理邏輯」。關於具體的欄位定義與資料格式,請參閱對應的專屬技能規範:
|
||||
> - **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 (心跳)**:高頻點,走 **MQTT 管線** (`machine/+/heartbeat`)。更新 `last_heard_at` 與感測器快照。
|
||||
2. **B013 (異常)**:事件驅動點,走 **MQTT 管線** (`machine/+/error`)。寫入 `machine_logs` 並觸發告警。
|
||||
3. **B600 (交易)**:高價值點,走 **MQTT 管線** (`machine/+/transaction`)。建立 `Transaction` 紀錄並支援重試。
|
||||
4. **B012 (商品同步)**:大資料量,走 **HTTP 管線**。應確保 Service 層具備緩存 (Cache) 機制。
|
||||
5. **B055 (遠端出貨)**:雲端下發指令,走 **MQTT 下行管線** (`machine/{id}/command`)。
|
||||
|
||||
---
|
||||
|
||||
> [!CAUTION]
|
||||
> **身份識別機制**:禁止在 Body 傳輸 `machine` 或 `key`。系統強制透過 `Bearer Token` 識別並自動關聯資料。
|
||||
|
||||
100
.agents/skills/mqtt-communication-specs/SKILL.md
Normal file
100
.agents/skills/mqtt-communication-specs/SKILL.md
Normal 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 輪詢模式以維持基礎通訊。
|
||||
@@ -67,8 +67,9 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
|
||||
|
||||
## 4. 實作檢查清單 (Checklist)
|
||||
|
||||
- [ ] **列表佈局**: 是否採用「整合式卡片」結構且內距設為 `p-8`?
|
||||
- [x] **列表佈局**: 是否採用「整合式卡片」結構且內距設為 `p-8`?
|
||||
- [ ] **分頁與總數**: 列表底部是否正確召喚 `vendor.pagination.luxury`?
|
||||
- [ ] **刪除動作**: 是否已全面使用 `<x-delete-confirm-modal />` 封裝執行路徑?
|
||||
- [ ] **文字色階**: 符合標題 `slate-900/white` 與標籤 `slate-500` 的對比度。
|
||||
- [ ] **可讀性檢查**: 二級資訊是否達到 `text-xs` (12px) 且權重不超過 `font-bold`?
|
||||
|
||||
@@ -136,7 +137,7 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||
<td class="px-6 py-6 font-extrabold text-slate-800 dark:text-slate-100 italic">Example Name</td>
|
||||
<td class="px-6 py-6 font-extrabold text-slate-800 dark:text-slate-100">Example Name</td>
|
||||
<td class="px-6 py-6 text-right"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -152,8 +153,9 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
|
||||
```
|
||||
|
||||
### 佈局核心原則:
|
||||
1. **移除重複內距**: 根容器 `div` 應**禁止**使用 `p-6` 或 `p-10`,因為佈局基底已提供基礎間距。僅使用 `space-y-6` (或 `space-y-8`) 控制區塊間隙。
|
||||
2. **主容器樣式**: 強制對齊為 `luxury-card rounded-3xl p-8`。
|
||||
1. **移除重複內距**: 根容器 `div` 應**禁止**使用 `p-6` 或 `p-10`,因為佈局基底已提供基礎間距。
|
||||
2. **區塊間隙**: 建議使用 `space-y-6` 或 `space-y-8` 以獲得最佳空氣感。但在「高密度資料管理」或使用者有特殊緊湊需求的情境下,容許縮減至 **`space-y-2`**。
|
||||
3. **主容器樣式**: 強制對齊為 `luxury-card rounded-3xl p-8`。
|
||||
3. **標題排版**:
|
||||
- 主標題需應用 `font-display` (Outfit)。
|
||||
- 描述文字需應用 `uppercase tracking-widest font-bold` 以呈現高級設計感。
|
||||
@@ -176,6 +178,25 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
|
||||
<option value="1">啟用</option>
|
||||
<option value="0">禁用</option>
|
||||
</select>
|
||||
|
||||
### 搜尋式下拉選單 (Searchable Select) - 【進階推薦】
|
||||
- **組件**: `<x-searchable-select />`
|
||||
- **適用場景**: 選項大於 10 筆或具備層級關聯的篩選器(如:公司名稱、機台編號)。
|
||||
- **奢華特徵**:
|
||||
- **動態旋轉箭頭**: 透過 `::after` 偽元素實作,選單展開時箭頭執行 `300ms` 的 180 度旋轉動畫。
|
||||
- **即時過濾**: 輸入關鍵字即時隱藏不匹配項。
|
||||
- **選取標示**: 選取的項目右側帶有青色 (`Cyan`) 的勾選小圖標。
|
||||
- **全部選項修復 (Space Fix)**: 若用於篩選(如公司篩選),組件內部已實作「空格佔位符」機制。若選單中的「全部」選項在選取後消失,請確保該選項的值為單個空格 (`value=" "`)。這能繞過 Preline 對空標記的隱藏邏輯,並同步觸發 Laravel 的 `blank()` 判定。
|
||||
|
||||
```html
|
||||
<x-searchable-select
|
||||
name="company_id"
|
||||
:options="$companies"
|
||||
:selected="request('company_id')"
|
||||
:placeholder="__('All Companies')"
|
||||
onchange="this.form.submit()"
|
||||
/>
|
||||
```
|
||||
```
|
||||
|
||||
## 8. 編輯與詳情頁規範 (Detail & Edit Views)
|
||||
@@ -212,61 +233,24 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
|
||||
|
||||
### 空間與反應 (Spacing & Interaction)
|
||||
- **單元格內距**: 統一使用 `px-6 py-6`。
|
||||
- **懸停反應**: 必須在 `tr` 套用 `group` 且子元素套用 `group-hover:bg-slate-50/80` (深色: `dark:group-hover:bg-slate-800/40`) 以提供高級的互動感知。
|
||||
- **圖示容器懸停 (Icon Hover Palette)**:
|
||||
- 列表左側的主圖示容器在 `group-hover` 時,應由淡色背景轉為 **實體主題色**。
|
||||
- 類別: `group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300`。
|
||||
- **文字同步變色**:
|
||||
- 主標題文字在 `group-hover` 時應同步變色,以強化點擊引導。
|
||||
- 類別: `group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors`。
|
||||
- **懸停反應**: 必須在 `tr` 套用 `group` 且子<EFBFBD>### 9.4 標竿刪除確認模式 (Luxury Delete Modal Pattern)
|
||||
當執行刪除或具備破壞性的操作時,**禁止**使用瀏覽器原生 `confirm()` 或簡易的 `x-modal`。全站統一使用 **`<x-delete-confirm-modal />`** Blade 組件進行二次確認。
|
||||
|
||||
### 分頁與列表控制項 (Pagination & Controls)
|
||||
為了維持操作一致性,所有列表的分頁與切換組件必須遵循以下「Luxury Jump」模式:
|
||||
- **統一高度**: 所有控制項(按鈕、下拉選單)固定為 `h-9` (36px)。
|
||||
- **筆數切換 (Limit Selector)**:
|
||||
- 規範: **禁止**在表格上方(Header/Toolbar)重複放置筆數切換選單。統一收納於底部分頁欄位。
|
||||
- **分頁導航 (Luxury Jump)**:
|
||||
- 模式: 捨棄傳統頁碼按鈕,全端統一使用「跳轉選單」。
|
||||
- 寬度: 下拉選單內部 Padding 為 `pl-4 pr-10`。
|
||||
- 字體: 使用 `text-xs font-black tracking-widest`。
|
||||
- **指示文字**:
|
||||
- 行動端隱藏多餘詞彙,僅保留「1 - 10 / 50」格式。
|
||||
- 數字顏色對齊 `text-slate-600` (深色: `text-slate-300`)。
|
||||
1. **參數配置**:
|
||||
- `title`: (選填) 預設為「確認刪除」。
|
||||
- `message`: (選填) 定義具體的刪除警告訊息(例如「您確定要永久刪除此帳號嗎?」)。
|
||||
2. **視覺特徵**:
|
||||
- **背景**: `bg-slate-900/60 backdrop-blur-sm`。
|
||||
- **容器**: `rounded-3xl shadow-2xl animate-luxury-in`。
|
||||
- **圖示**: 警告圖示使用 `bg-amber-100/10 text-amber-600`。
|
||||
- **按鈕**: 刪除按鈕使用 `bg-rose-500` 搭配 `shadow-rose-200` 投影,取消按鈕使用 `bg-slate-100`。
|
||||
3. **交互規範**:
|
||||
- **禁止斜體 (No Italics)**: 彈窗標題與按鈕文字嚴禁使用 `italic`,保持直挺專業感。
|
||||
|
||||
### 底部清單控制項 (Bottom List Controls)
|
||||
為了確保長列表的操作便利,清單底部應保持乾淨,統一由分頁與總數組件接管操作。
|
||||
|
||||
### 標準操作按鈕 (Standard Action Icons)
|
||||
表格內的操作欄位(如「編輯」、「刪除」、「詳情」)必須使用以下定義之 **「黃金標準 (Gold Standard)」**:
|
||||
|
||||
- **共同樣式**:
|
||||
- 容器: `p-2 rounded-lg bg-slate-50 dark:bg-slate-800`
|
||||
- 主色: `text-slate-400`
|
||||
- 邊框: `border border-transparent` (防閃爍處理)
|
||||
- 過渡: `transition-all` (使用預設速度以確保俐落感)
|
||||
- 圖示粗細: `stroke-width="2.5"`
|
||||
- 尺寸: `w-4 h-4`
|
||||
|
||||
- **編輯按鈕 (Edit)**:
|
||||
- 懸停特效: `hover:text-cyan-500 hover:bg-cyan-500/10 hover:border-cyan-500/20`
|
||||
- SVG 路徑:
|
||||
```html
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
||||
```
|
||||
|
||||
- **查看詳情 (View/Detail)**:
|
||||
- 懸停特效: `hover:text-indigo-500 hover:bg-indigo-500/10 hover:border-indigo-500/20`
|
||||
- SVG 路徑:
|
||||
```html
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="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"/></svg>
|
||||
```
|
||||
|
||||
- **刪除按鈕 (Delete)**:
|
||||
- 懸停特效: `hover:text-rose-500 hover:bg-rose-500/10 hover:border-rose-500/20`
|
||||
- SVG 路徑:
|
||||
```html
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
|
||||
```
|
||||
```html
|
||||
<!-- 使用範例 -->
|
||||
<x-delete-confirm-modal :message="__('Are you sure you want to delete this account?')" />
|
||||
```
|
||||
|
||||
## 10. 系統兼容性與標準化 (Compatibility & Standardization)
|
||||
|
||||
@@ -296,6 +280,47 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
|
||||
- **權重載入 (Font Weights)**: 確保 HTML Header 載入了 `800` 與 `900` 權重,避免瀏覽器模擬出的假粗體。
|
||||
- **清單資訊密度**: 對於高密度清單中的時間資訊,應優先使用 `font-black` 與 `tracking-widest` 來建立明確的「標籤感」,而非僅僅是「微縮文字」。
|
||||
|
||||
---
|
||||
## 12. 提示與告警規範 (Alerts & Notifications)
|
||||
|
||||
為了確保全站操作回饋的一致性與專業感,所有系統內部的提示(成功、錯誤、警告)必須遵循以下規範。
|
||||
|
||||
### 1. 懸浮式自動消失提示 (Auto-hiding Toasts)
|
||||
- **視覺樣式**:
|
||||
- 位置: 固定於畫面上方中央 (`fixed top-8 left-1/2 -translate-x-1/2`)。
|
||||
- 特效: 毛玻璃背景 (`backdrop-blur-xl`)、圓角 (`rounded-2xl`)、軟陰影。
|
||||
- 動畫: 滑入 (`translate-y-0`) / 滑出 (`-translate-y-4`),配合 `opacity` 變化。
|
||||
- **型態定義**:
|
||||
- **Success (成功)**: 使用 `emerald` 色系。
|
||||
- **Error (錯誤)**: 使用 `rose` 色系。
|
||||
- **時長規範**:
|
||||
- 成功提示: 3 秒後消失。
|
||||
- 錯誤提示: 5 秒後消失(提供使用者更多閱讀錯誤原因的時間)。
|
||||
- **組件實作**: 統一調用 `<x-toast />`。
|
||||
|
||||
### 2. 視窗內操作警告 (Inline Action Warnings)
|
||||
- **適用場景**: 在 Modal 或編輯頁面中,提示可能導致風險的操作(如編輯自身角色)。
|
||||
- **視覺樣式**:
|
||||
- 背景: `bg-amber-500/10` (琥珀色)。
|
||||
- 邊框: `border-amber-500/20`。
|
||||
- 進場動畫: `animate-luxury-in`。
|
||||
- **實作範例**:
|
||||
```html
|
||||
<div class="p-5 bg-amber-500/10 border border-amber-500/20 text-amber-600 rounded-2xl flex items-start gap-4 animate-luxury-in font-bold">
|
||||
<!-- Icon & Text -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. 通用豪華確認與告警視窗 (General Luxury Modals)
|
||||
**統一準則**: 所有的系統確認 (Confirm) 或重要告警 (Alert/Warning) **必須** 捨棄 `x-modal` 組件,改用 Section 9.4 定義的自定義 Div 結構。
|
||||
|
||||
- **警告模式 (Warning/Alert)**:
|
||||
- 僅提供「關閉/確定」一個按鈕。
|
||||
- 樣式同 9.4,但隱藏刪除 Form 與相關色彩。
|
||||
- **確認模式 (Confirm)**:
|
||||
- 提供「取消」與「執行」兩個按鈕。
|
||||
- 執行按鈕顏色視操作性質而定 (Delete: `rose`, Save/Action: `cyan`)。
|
||||
|
||||
---
|
||||
> [!IMPORTANT]
|
||||
> **開發新功能前,必須確認 `app.css` 中的 `.btn-luxury-*` 系列組件是否滿足需求。**
|
||||
|
||||
@@ -96,6 +96,7 @@ jobs:
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
php artisan view:cache &&
|
||||
php artisan queue:restart &&
|
||||
php artisan db:seed --class=RoleSeeder --force &&
|
||||
php artisan db:seed --class=AdminUserSeeder --force
|
||||
"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,5 +19,6 @@ yarn-error.log
|
||||
/.vscode
|
||||
/docs/API
|
||||
/docs/*.xlsx
|
||||
/docs/pptx
|
||||
|
||||
|
||||
|
||||
270
app/Http/Controllers/Admin/AdvertisementController.php
Normal file
270
app/Http/Controllers/Admin/AdvertisementController.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineAdvertisement;
|
||||
use App\Models\System\Advertisement;
|
||||
use App\Models\System\Company;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AdvertisementController extends AdminController
|
||||
{
|
||||
use ImageHandler;
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$tab = $request->input('tab', 'list');
|
||||
|
||||
// Tab 1: 廣告列表
|
||||
$advertisements = Advertisement::with('company')->latest()->paginate(10);
|
||||
|
||||
// Tab 2: 機台廣告設置 (所需資料) - 隱藏已過期的廣告
|
||||
$allAds = Advertisement::playing()->get();
|
||||
|
||||
// Tab 2: 機台廣告設置 (所需資料)
|
||||
// 取得使用者有權限的機台列表 (已透過 Global Scope 過濾)
|
||||
$machines = Machine::select('id', 'name', 'serial_no', 'company_id')->get();
|
||||
|
||||
$companies = $user->isSystemAdmin() ? Company::orderBy('name')->get() : collect();
|
||||
|
||||
return view('admin.ads.index', [
|
||||
'advertisements' => $advertisements,
|
||||
'machines' => $machines,
|
||||
'tab' => $tab,
|
||||
'allAds' => $allAds,
|
||||
'companies' => $companies,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材 CRUD: 儲存廣告
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|in:image,video',
|
||||
'duration' => 'required|in:15,30,60',
|
||||
'file' => [
|
||||
'required',
|
||||
'file',
|
||||
'mimes:jpeg,png,jpg,gif,webp,mp4,mov,avi',
|
||||
$request->type === 'image' ? 'max:10240' : 'max:51200', // Image 10MB, Video 50MB
|
||||
],
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'start_at' => 'nullable|date',
|
||||
'end_at' => 'nullable|date|after_or_equal:start_at',
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
$file = $request->file('file');
|
||||
|
||||
if ($request->type === 'image') {
|
||||
$path = $this->storeAsWebp($file, 'ads');
|
||||
} else {
|
||||
$path = $file->store('ads', 'public');
|
||||
}
|
||||
|
||||
if ($user->isSystemAdmin()) {
|
||||
$companyId = $request->filled('company_id') ? $request->company_id : null;
|
||||
} else {
|
||||
$companyId = $user->company_id;
|
||||
}
|
||||
|
||||
$advertisement = Advertisement::create([
|
||||
'company_id' => $companyId,
|
||||
'name' => $request->name,
|
||||
'type' => $request->type,
|
||||
'duration' => (int) $request->duration,
|
||||
'url' => Storage::disk('public')->url($path),
|
||||
'is_active' => true,
|
||||
'start_at' => $request->start_at,
|
||||
'end_at' => $request->end_at,
|
||||
]);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement created successfully.'),
|
||||
'data' => $advertisement
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Advertisement created successfully.'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Advertisement $advertisement)
|
||||
{
|
||||
$rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|in:image,video',
|
||||
'duration' => 'required|in:15,30,60',
|
||||
'is_active' => 'boolean',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'start_at' => 'nullable|date',
|
||||
'end_at' => 'nullable|date|after_or_equal:start_at',
|
||||
];
|
||||
|
||||
if ($request->hasFile('file')) {
|
||||
$rules['file'] = [
|
||||
'file',
|
||||
'mimes:jpeg,png,jpg,gif,webp,mp4,mov,avi',
|
||||
$request->type === 'image' ? 'max:10240' : 'max:51200',
|
||||
];
|
||||
}
|
||||
|
||||
$request->validate($rules);
|
||||
|
||||
$data = $request->only(['name', 'type', 'duration', 'start_at', 'end_at']);
|
||||
$data['is_active'] = $request->has('is_active');
|
||||
|
||||
$user = auth()->user();
|
||||
if ($user->isSystemAdmin()) {
|
||||
$data['company_id'] = $request->filled('company_id') ? $request->company_id : null;
|
||||
}
|
||||
|
||||
if ($request->hasFile('file')) {
|
||||
// 刪除舊檔案
|
||||
// 處理 URL 可能包含 storage 或原始路徑的情況
|
||||
$oldPath = str_replace(Storage::disk('public')->url(''), '', $advertisement->url);
|
||||
// 去除開頭可能的斜線
|
||||
$oldPath = ltrim($oldPath, '/');
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
|
||||
// 存入新檔案
|
||||
$file = $request->file('file');
|
||||
if ($request->type === 'image') {
|
||||
$path = $this->storeAsWebp($file, 'ads');
|
||||
} else {
|
||||
$path = $file->store('ads', 'public');
|
||||
}
|
||||
$data['url'] = Storage::disk('public')->url($path);
|
||||
}
|
||||
|
||||
$advertisement->update($data);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement updated successfully.'),
|
||||
'data' => $advertisement
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Advertisement updated successfully.'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Advertisement $advertisement)
|
||||
{
|
||||
// 檢查是否有機台正投放中
|
||||
if ($advertisement->machineAdvertisements()->exists()) {
|
||||
return redirect()->back()->with('error', __('Cannot delete advertisement being used by machines.'));
|
||||
}
|
||||
|
||||
// 刪除實體檔案
|
||||
$path = str_replace(Storage::disk('public')->url(''), '', $advertisement->url);
|
||||
Storage::disk('public')->delete($path);
|
||||
|
||||
$advertisement->delete();
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement deleted successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Advertisement deleted successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 取得特定機台的廣告投放清單
|
||||
*/
|
||||
public function getMachineAds(Machine $machine)
|
||||
{
|
||||
$assignments = MachineAdvertisement::where('machine_id', $machine->id)
|
||||
->with('advertisement')
|
||||
->orderBy('sort_order', 'asc')
|
||||
->get()
|
||||
->groupBy('position');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $assignments
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 投放廣告至機台
|
||||
*/
|
||||
public function assign(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'machine_id' => 'required|exists:machines,id',
|
||||
'advertisement_id' => 'required|exists:advertisements,id',
|
||||
'position' => 'required|in:vending,visit_gift,standby',
|
||||
'sort_order' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
// If sort_order is not provided, append to the end of the current position list
|
||||
$newSortOrder = $request->sort_order;
|
||||
if (is_null($newSortOrder)) {
|
||||
$newSortOrder = MachineAdvertisement::where('machine_id', $request->machine_id)
|
||||
->where('position', $request->position)
|
||||
->max('sort_order') + 1;
|
||||
}
|
||||
|
||||
MachineAdvertisement::updateOrCreate(
|
||||
[
|
||||
'machine_id' => $request->machine_id,
|
||||
'position' => $request->position,
|
||||
'advertisement_id' => $request->advertisement_id,
|
||||
],
|
||||
[
|
||||
'sort_order' => $newSortOrder,
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement assigned successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新排序廣告播放順序
|
||||
*/
|
||||
public function reorderAssignments(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'assignment_ids' => 'required|array',
|
||||
'assignment_ids.*' => 'exists:machine_advertisements,id'
|
||||
]);
|
||||
|
||||
foreach ($request->assignment_ids as $index => $id) {
|
||||
MachineAdvertisement::where('id', $id)->update(['sort_order' => $index]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Order updated successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除廣告投放
|
||||
*/
|
||||
public function removeAssignment($id)
|
||||
{
|
||||
$assignment = MachineAdvertisement::findOrFail($id);
|
||||
$assignment->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Assignment removed successfully.')
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin\BasicSettings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -11,6 +12,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MachinePhotoController extends Controller
|
||||
{
|
||||
use ImageHandler;
|
||||
|
||||
/**
|
||||
* 更新機台照片
|
||||
*/
|
||||
@@ -20,6 +23,12 @@ class MachinePhotoController extends Controller
|
||||
'machine_id' => $machine->id,
|
||||
'files' => $request->allFiles()
|
||||
]);
|
||||
|
||||
$request->validate([
|
||||
'machine_image_0' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'machine_image_1' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'machine_image_2' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
]);
|
||||
|
||||
try {
|
||||
$images = $machine->images ?? [];
|
||||
@@ -64,50 +73,4 @@ class MachinePhotoController extends Controller
|
||||
return back()->with('error', __('Failed to update machine images: ') . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 將圖片轉換為 WebP 並儲存
|
||||
*/
|
||||
protected function storeAsWebp($file, $directory): string
|
||||
{
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$filename = uniqid() . '.webp';
|
||||
$path = "{$directory}/{$filename}";
|
||||
|
||||
// 讀取原始圖片
|
||||
$imageType = exif_imagetype($file->getRealPath());
|
||||
switch ($imageType) {
|
||||
case IMAGETYPE_JPEG:
|
||||
$source = imagecreatefromjpeg($file->getRealPath());
|
||||
break;
|
||||
case IMAGETYPE_PNG:
|
||||
$source = imagecreatefrompng($file->getRealPath());
|
||||
break;
|
||||
case IMAGETYPE_WEBP:
|
||||
$source = imagecreatefromwebp($file->getRealPath());
|
||||
break;
|
||||
default:
|
||||
// 如果格式不支援,直接存
|
||||
return $file->storeAs($directory, $file->hashName(), 'public');
|
||||
}
|
||||
|
||||
if (!$source) {
|
||||
return $file->storeAs($directory, $file->hashName(), 'public');
|
||||
}
|
||||
|
||||
// 確保支援真彩色(解決 palette image 問題)
|
||||
if (!imageistruecolor($source)) {
|
||||
imagepalettetotruecolor($source);
|
||||
}
|
||||
|
||||
// 捕捉輸出
|
||||
ob_start();
|
||||
imagewebp($source, null, 80);
|
||||
$content = ob_get_clean();
|
||||
imagedestroy($source);
|
||||
|
||||
Storage::disk('public')->put($path, $content);
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Admin\AdminController;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineModel;
|
||||
use App\Models\System\PaymentConfig;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -16,13 +17,15 @@ use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MachineSettingController extends AdminController
|
||||
{
|
||||
use ImageHandler;
|
||||
|
||||
/**
|
||||
* 顯示機台與型號設定列表 (採用標籤頁整合)
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$tab = $request->input('tab', 'machines');
|
||||
$per_page = $request->input('per_page', 20);
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$search = $request->input('search');
|
||||
|
||||
// 1. 處理機台清單 (Machines Tab)
|
||||
@@ -33,23 +36,45 @@ class MachineSettingController extends AdminController
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
$machines = $machineQuery->latest()->paginate($per_page, ['*'], 'machines_page')->withQueryString();
|
||||
$machines = $machineQuery->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
// 2. 處理型號清單 (Models Tab)
|
||||
$modelQuery = MachineModel::query()->withCount('machines');
|
||||
if ($tab === 'models' && $search) {
|
||||
$modelQuery->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
$models_list = $modelQuery->latest()->paginate($per_page, ['*'], 'models_page')->withQueryString();
|
||||
$models_list = $modelQuery->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
// 3. 基礎下拉資料 (用於新增/編輯機台的彈窗)
|
||||
// 3. 處理機台權限 (Permissions Tab) - 僅顯示 is_admin 帳號
|
||||
$users_list = null;
|
||||
if ($tab === 'permissions') {
|
||||
$userQuery = \App\Models\System\User::query()
|
||||
->where('is_admin', true)
|
||||
->with(['company', 'machines']);
|
||||
|
||||
if ($search) {
|
||||
$userQuery->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
$userQuery->where('company_id', $request->company_id);
|
||||
}
|
||||
|
||||
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString();
|
||||
}
|
||||
|
||||
// 4. 基礎下拉資料 (用於新增/編輯機台的彈窗)
|
||||
$models = MachineModel::select('id', 'name')->get();
|
||||
$paymentConfigs = PaymentConfig::select('id', 'name')->get();
|
||||
$companies = \App\Models\System\Company::select('id', 'name')->get();
|
||||
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
|
||||
|
||||
return view('admin.basic-settings.machines.index', compact(
|
||||
'machines',
|
||||
'models_list',
|
||||
'users_list',
|
||||
'models',
|
||||
'paymentConfigs',
|
||||
'companies',
|
||||
@@ -65,22 +90,22 @@ class MachineSettingController extends AdminController
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'serial_no' => 'required|string|unique:machines,serial_no',
|
||||
'company_id' => 'required|exists:companies,id',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'machine_model_id' => 'required|exists:machine_models,id',
|
||||
'payment_config_id' => 'nullable|exists:payment_configs,id',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'images.*' => 'image|mimes:jpeg,png,jpg,gif|max:2048',
|
||||
'images.*' => 'image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB
|
||||
]);
|
||||
|
||||
$imagePaths = [];
|
||||
if ($request->hasFile('images')) {
|
||||
foreach (array_slice($request->file('images'), 0, 3) as $image) {
|
||||
$imagePaths[] = $this->processAndStoreImage($image);
|
||||
$imagePaths[] = $this->storeAsWebp($image, 'machines');
|
||||
}
|
||||
}
|
||||
|
||||
$machine = Machine::create(array_merge($validated, [
|
||||
'status' => 'offline',
|
||||
'api_token' => \Illuminate\Support\Str::random(60),
|
||||
'creator_id' => auth()->id(),
|
||||
'updater_id' => auth()->id(),
|
||||
'card_reader_seconds' => 30, // 預設值
|
||||
@@ -101,7 +126,7 @@ class MachineSettingController extends AdminController
|
||||
{
|
||||
$models = MachineModel::select('id', 'name')->get();
|
||||
$paymentConfigs = PaymentConfig::select('id', 'name')->get();
|
||||
$companies = \App\Models\System\Company::select('id', 'name')->get();
|
||||
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
|
||||
|
||||
return view('admin.basic-settings.machines.edit', compact('machine', 'models', 'paymentConfigs', 'companies'));
|
||||
}
|
||||
@@ -116,6 +141,7 @@ class MachineSettingController extends AdminController
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'serial_no' => 'sometimes|required|string|unique:machines,serial_no,' . $machine->id,
|
||||
'card_reader_seconds' => 'required|integer|min:0',
|
||||
'payment_buffer_seconds' => 'required|integer|min:0',
|
||||
'card_reader_checkout_time_1' => 'nullable|string',
|
||||
@@ -136,7 +162,20 @@ class MachineSettingController extends AdminController
|
||||
'machine_model_id' => 'required|exists:machine_models,id',
|
||||
'payment_config_id' => 'nullable|exists:payment_configs,id',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'image_0' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'image_1' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'image_2' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'remove_image_0' => 'nullable|boolean',
|
||||
'remove_image_1' => 'nullable|boolean',
|
||||
'remove_image_2' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// 僅限系統管理員可修改公司
|
||||
if (auth()->user()->isSystemAdmin()) {
|
||||
$companyRule = ['company_id' => 'nullable|exists:companies,id'];
|
||||
$companyData = $request->validate($companyRule);
|
||||
$validated = array_merge($validated, $companyData);
|
||||
}
|
||||
|
||||
Log::info('Machine Update Validated Data', ['data' => $validated]);
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
@@ -144,82 +183,71 @@ class MachineSettingController extends AdminController
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$machine->update(array_merge($validated, [
|
||||
// 排除虛擬欄位 (圖片上傳、移除標記),這些欄位不在資料表內
|
||||
$dataToUpdate = \Illuminate\Support\Arr::except($validated, [
|
||||
'image_0', 'image_1', 'image_2',
|
||||
'remove_image_0', 'remove_image_1', 'remove_image_2'
|
||||
]);
|
||||
|
||||
$machine->update(array_merge($dataToUpdate, [
|
||||
'updater_id' => auth()->id(),
|
||||
]));
|
||||
|
||||
// 處理圖片更新 (支援 3 個獨立槽位)
|
||||
if ($request->hasFile('images')) {
|
||||
$currentImages = $machine->images ?? [];
|
||||
$newImages = $request->file('images');
|
||||
$updated = false;
|
||||
// 處理圖片更新 (支援 3 個獨立槽位: image_0, image_1, image_2)
|
||||
$currentImages = $machine->images ?? [];
|
||||
$updated = false;
|
||||
|
||||
foreach ($newImages as $index => $file) {
|
||||
// 限制 3 個槽位 (0, 1, 2)
|
||||
if ($index < 0 || $index > 2) continue;
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$inputName = "image_$i";
|
||||
$removeName = "remove_image_$i";
|
||||
|
||||
// 刪除該槽位的舊圖
|
||||
if (isset($currentImages[$index]) && !empty($currentImages[$index])) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$index]);
|
||||
// 如果有新圖片上傳
|
||||
if ($request->hasFile($inputName)) {
|
||||
// 刪除舊圖
|
||||
if (isset($currentImages[$i]) && !empty($currentImages[$i])) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$i]);
|
||||
}
|
||||
|
||||
// 處理並儲存新圖
|
||||
$currentImages[$index] = $this->processAndStoreImage($file);
|
||||
// 儲存新圖
|
||||
$currentImages[$i] = $this->storeAsWebp($request->file($inputName), 'machines');
|
||||
$updated = true;
|
||||
}
|
||||
// 否則,如果有刪除標記
|
||||
elseif ($request->input($removeName) === '1') {
|
||||
if (isset($currentImages[$i]) && !empty($currentImages[$i])) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$i]);
|
||||
unset($currentImages[$i]);
|
||||
$updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($updated) {
|
||||
ksort($currentImages);
|
||||
$machine->update(['images' => array_values($currentImages)]);
|
||||
}
|
||||
if ($updated) {
|
||||
ksort($currentImages);
|
||||
$machine->update(['images' => array_values($currentImages)]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index')
|
||||
->with('success', __('Machine settings updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 處理並儲存圖片 (轉換為 WebP 並調整大小)
|
||||
*/
|
||||
protected function processAndStoreImage($file)
|
||||
public function regenerateToken(Request $request, $serial): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$path = 'machines/' . \Illuminate\Support\Str::random(40) . '.webp';
|
||||
|
||||
// 載入原圖
|
||||
$imageInfo = getimagesize($file->getRealPath());
|
||||
$mime = $imageInfo['mime'];
|
||||
|
||||
switch ($mime) {
|
||||
case 'image/jpeg':
|
||||
$image = imagecreatefromjpeg($file->getRealPath());
|
||||
break;
|
||||
case 'image/png':
|
||||
$image = imagecreatefrompng($file->getRealPath());
|
||||
break;
|
||||
case 'image/gif':
|
||||
$image = imagecreatefromgif($file->getRealPath());
|
||||
break;
|
||||
default:
|
||||
return $file->store('machines', 'public');
|
||||
}
|
||||
$machine = Machine::where('serial_no', $serial)->firstOrFail();
|
||||
$newToken = \Illuminate\Support\Str::random(60);
|
||||
$machine->update(['api_token' => $newToken]);
|
||||
|
||||
if ($image) {
|
||||
// [修正] imagewebp(): Palette image not supported by webp
|
||||
// 若為 Palette 圖片 (例如 GIF),轉換為 Truecolor
|
||||
if (!imageistruecolor($image)) {
|
||||
imagepalettetotruecolor($image);
|
||||
}
|
||||
Log::info('Machine API Token Regenerated', [
|
||||
'machine_id' => $machine->id,
|
||||
'serial_no' => $machine->serial_no,
|
||||
'user_id' => auth()->id()
|
||||
]);
|
||||
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->makeDirectory('machines');
|
||||
$fullPath = \Illuminate\Support\Facades\Storage::disk('public')->path($path);
|
||||
|
||||
// 轉換並儲存 (品質 80)
|
||||
imagewebp($image, $fullPath, 80);
|
||||
imagedestroy($image);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
return $file->store('machines', 'public');
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('API Token regenerated successfully.'),
|
||||
'api_token' => $newToken
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ class PaymentConfigController extends AdminController
|
||||
{
|
||||
$per_page = $request->input('per_page', 20);
|
||||
$configs = PaymentConfig::query()
|
||||
->when($request->search, function ($query, $search) {
|
||||
$query->where('name', 'like', "%{$search}%")
|
||||
->orWhereHas('company', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->with(['company', 'creator'])
|
||||
->latest()
|
||||
->paginate($per_page)
|
||||
@@ -32,7 +38,7 @@ class PaymentConfigController extends AdminController
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
$companies = \App\Models\System\Company::select('id', 'name')->get();
|
||||
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
|
||||
return view('admin.basic-settings.payment-configs.create', compact('companies'));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ class CompanyController extends Controller
|
||||
*/
|
||||
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')) {
|
||||
@@ -32,9 +33,9 @@ class CompanyController extends Controller
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$companies = $query->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
// 取得可供選擇的客戶角色範本 (is_system = 0, company_id = null)
|
||||
$template_roles = \App\Models\System\Role::where('is_system', 0)
|
||||
->whereNull('company_id')
|
||||
// 取得可供選擇的客戶角色範本 (系統層級的角色,排除 super-admin)
|
||||
$template_roles = \App\Models\System\Role::whereNull('company_id')
|
||||
->where('name', '!=', 'super-admin')
|
||||
->get();
|
||||
|
||||
return view('admin.companies.index', compact('companies', 'template_roles'));
|
||||
@@ -48,13 +49,20 @@ class CompanyController extends Controller
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'required|string|max:50|unique:companies,code',
|
||||
'original_type' => 'required|string|in:buyout,lease',
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'valid_until' => 'nullable|date',
|
||||
'start_date' => 'required|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',
|
||||
'note' => 'nullable|string',
|
||||
'settings' => 'nullable|array',
|
||||
// 帳號相關欄位 (可選)
|
||||
'admin_username' => 'nullable|string|max:255|unique:users,username',
|
||||
'admin_password' => 'nullable|string|min:8',
|
||||
@@ -62,17 +70,44 @@ class CompanyController extends Controller
|
||||
'admin_role' => 'nullable|string|exists:roles,name',
|
||||
]);
|
||||
|
||||
// 確保 settings 中的值為布林值
|
||||
if (isset($validated['settings'])) {
|
||||
$validated['settings']['enable_material_code'] = filter_var($validated['settings']['enable_material_code'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['enable_points'] = filter_var($validated['settings']['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($validated) {
|
||||
$company = Company::create([
|
||||
'name' => $validated['name'],
|
||||
'code' => $validated['code'],
|
||||
'original_type' => $validated['original_type'],
|
||||
'current_type' => $validated['original_type'], // 新增時同步
|
||||
'tax_id' => $validated['tax_id'] ?? null,
|
||||
'contact_name' => $validated['contact_name'] ?? null,
|
||||
'contact_phone' => $validated['contact_phone'] ?? null,
|
||||
'contact_email' => $validated['contact_email'] ?? null,
|
||||
'valid_until' => $validated['valid_until'] ?? null,
|
||||
'start_date' => $validated['start_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'],
|
||||
'note' => $validated['note'] ?? null,
|
||||
'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(),
|
||||
]);
|
||||
|
||||
// 如果有填寫帳號資訊,則建立管理員帳號
|
||||
@@ -86,23 +121,23 @@ class CompanyController extends Controller
|
||||
]);
|
||||
|
||||
// 角色初始化與克隆邏輯 (優先使用選擇的角色,否則使用預設)
|
||||
$selected_role_name = $validated['admin_role'] ?? '通用客戶角色範本';
|
||||
$role_to_assign = '管理員';
|
||||
$selected_role_name = $validated['admin_role'] ?? '客戶管理員角色模板';
|
||||
$role_to_assign = null;
|
||||
|
||||
$template_role = \App\Models\System\Role::where('name', $selected_role_name)
|
||||
->whereNull('company_id')
|
||||
->where('is_system', 0)
|
||||
->where('name', '!=', 'super-admin')
|
||||
->first();
|
||||
|
||||
if ($template_role) {
|
||||
// 克隆範本為該公司的「管理員」
|
||||
$clonedRole = \App\Models\System\Role::query()->create([
|
||||
$role_to_assign = \App\Models\System\Role::query()->create([
|
||||
'name' => '管理員',
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $company->id,
|
||||
'is_system' => false,
|
||||
]);
|
||||
$clonedRole->syncPermissions($template_role->permissions);
|
||||
$role_to_assign->syncPermissions($template_role->getPermissionNames());
|
||||
} else {
|
||||
// 如果找不到選定的角色範本,退而求其次嘗試指派現有角色 (通常不應發生)
|
||||
$role_to_assign = $selected_role_name;
|
||||
@@ -123,20 +158,70 @@ class CompanyController extends Controller
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'required|string|max:50|unique:companies,code,' . $company->id,
|
||||
'current_type' => 'required|string|in:buyout,lease',
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'valid_until' => 'nullable|date',
|
||||
'start_date' => 'required|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',
|
||||
'note' => 'nullable|string',
|
||||
'settings' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$company->update($validated);
|
||||
// 確保 settings 中的值為布林值,避免 JSON 存儲為字串導致前端判斷錯誤
|
||||
if (isset($validated['settings'])) {
|
||||
$validated['settings']['enable_material_code'] = filter_var($validated['settings']['enable_material_code'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['enable_points'] = filter_var($validated['settings']['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
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) {
|
||||
$company->users()->update(['status' => 0]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Customer updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 切換客戶狀態
|
||||
*/
|
||||
public function toggleStatus(Company $company)
|
||||
{
|
||||
$newStatus = $company->status == 1 ? 0 : 1;
|
||||
$company->update(['status' => $newStatus]);
|
||||
|
||||
// 若切換為停用,同步更新所有旗下帳號
|
||||
if ($newStatus == 0) {
|
||||
$company->users()->update(['status' => 0]);
|
||||
return redirect()->back()->with('success', __('Customer and associated accounts disabled successfully.'));
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Customer enabled successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
@@ -146,6 +231,11 @@ class CompanyController extends Controller
|
||||
return redirect()->back()->with('error', __('Cannot delete company with active accounts.'));
|
||||
}
|
||||
|
||||
// 為了解決軟刪除導致的唯一索引佔用問題,刪除前先重新命名唯一欄位
|
||||
$timestamp = now()->getTimestamp();
|
||||
$company->code = $company->code . '.deleted.' . $timestamp;
|
||||
$company->save();
|
||||
|
||||
$company->delete();
|
||||
|
||||
return redirect()->back()->with('success', __('Customer deleted successfully.'));
|
||||
|
||||
@@ -12,28 +12,31 @@ class DashboardController extends Controller
|
||||
{
|
||||
// 每頁顯示筆數限制 (預設為 10)
|
||||
$perPage = (int) request()->input('per_page', 10);
|
||||
if ($perPage <= 0) $perPage = 10;
|
||||
|
||||
if ($perPage <= 0)
|
||||
$perPage = 10;
|
||||
|
||||
// 從資料庫獲取真實統計數據
|
||||
$totalRevenue = \App\Models\Member\MemberWallet::sum('balance');
|
||||
$activeMachines = Machine::where('status', 'online')->count();
|
||||
$alertsPending = Machine::where('status', 'error')->count();
|
||||
$totalRevenue = \App\Models\Member\MemberWallet::sum('balance');
|
||||
$activeMachines = Machine::online()->count();
|
||||
$offlineMachines = Machine::offline()->count();
|
||||
$alertsPending = Machine::hasError()->count();
|
||||
$memberCount = \App\Models\Member\Member::count();
|
||||
|
||||
// 獲取機台列表 (分頁)
|
||||
$machines = Machine::when($request->search, function($query, $search) {
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->latest()
|
||||
$machines = Machine::when($request->search, function ($query, $search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->orderByDesc('last_heartbeat_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.dashboard', compact(
|
||||
'totalRevenue',
|
||||
'activeMachines',
|
||||
'offlineMachines',
|
||||
'alertsPending',
|
||||
'memberCount',
|
||||
'machines'
|
||||
|
||||
@@ -25,15 +25,6 @@ class DataConfigController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
// 管理者可賣商品
|
||||
public function adminProducts()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '商品狀態',
|
||||
'description' => '管理者商品銷售權限',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
// 子帳號管理
|
||||
public function subAccounts()
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Machine;
|
||||
|
||||
use App\Http\Controllers\Admin\AdminController;
|
||||
use App\Models\System\Company;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\System\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MachinePermissionController extends AdminController
|
||||
{
|
||||
/**
|
||||
* 顯示機台權限管理列表
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$search = $request->input('search');
|
||||
$company_id = $request->input('company_id');
|
||||
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 僅列出租戶中具有「is_admin」標記的角色帳號以供分配
|
||||
$userQuery = User::query()
|
||||
->with(['machines' => function($query) {
|
||||
$query->withoutGlobalScope('machine_access')
|
||||
->select('machines.id', 'machines.name', 'machines.serial_no');
|
||||
}])
|
||||
->whereNotNull('company_id');
|
||||
|
||||
// 非系統管理員僅能看到同公司的帳號 (因 User Model 排除 TenantScoped 全域過濾,需手動注入)
|
||||
if (!$currentUser->isSystemAdmin()) {
|
||||
$userQuery->where('company_id', $currentUser->company_id);
|
||||
} elseif ($company_id) {
|
||||
// 系統管理員的篩選邏輯
|
||||
$userQuery->where('company_id', $company_id);
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$userQuery->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString();
|
||||
$companies = $currentUser->isSystemAdmin() ? Company::all() : collect();
|
||||
|
||||
return view('admin.machines.permissions', compact('users_list', 'companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 取得特定帳號的機台分配狀態
|
||||
*/
|
||||
public function getAccountMachines(User $user): JsonResponse
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 安全檢查:只能操作自己公司的帳號(除非是系統管理員)
|
||||
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
// 取得該使用者所屬公司之所有機台 (忽略個別帳號的 machine_access 限制,以公司為單位顯示)
|
||||
$machines = Machine::withoutGlobalScope('machine_access')
|
||||
->where('company_id', $user->company_id)
|
||||
->get(['id', 'name', 'serial_no']);
|
||||
|
||||
$assignedIds = $user->machines()->pluck('machines.id')->toArray();
|
||||
|
||||
return response()->json([
|
||||
'user' => $user,
|
||||
'machines' => $machines,
|
||||
'assigned_ids' => $assignedIds
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 儲存特定帳號的機台分配
|
||||
*/
|
||||
public function syncAccountMachines(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 安全檢查
|
||||
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'machine_ids' => 'nullable|array',
|
||||
'machine_ids.*' => 'exists:machines,id'
|
||||
]);
|
||||
|
||||
// 加固驗證:確保所有機台 ID 都屬於該使用者的公司 (使用 withoutGlobalScope 避免管理員自身權限影響驗證邏輯)
|
||||
if ($request->has('machine_ids')) {
|
||||
$machineIds = array_unique($request->machine_ids);
|
||||
$validCount = Machine::withoutGlobalScope('machine_access')
|
||||
->where('company_id', $user->company_id)
|
||||
->whereIn('id', $machineIds)
|
||||
->count();
|
||||
|
||||
if ($validCount !== count($machineIds)) {
|
||||
return response()->json(['error' => 'Invalid machine IDs provided.'], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$user->machines()->sync($request->machine_ids ?? []);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Permissions updated successfully'),
|
||||
'assigned_machines' => $user->machines()->select('machines.id', 'machines.name', 'machines.serial_no')->get()
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,92 +3,186 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Services\Machine\MachineService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MachineController extends AdminController
|
||||
{
|
||||
/**
|
||||
* 顯示所有機台列表
|
||||
*/
|
||||
public function __construct(protected MachineService $machineService)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$per_page = $request->input('per_page', 10);
|
||||
|
||||
$query = Machine::query();
|
||||
|
||||
// 搜尋:名稱或序號
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$machines = $query->when($request->status, function ($query, $status) {
|
||||
return $query->where('status', $status);
|
||||
})
|
||||
->latest()
|
||||
// 預加載統計資料
|
||||
$machines = $query->orderBy("last_heartbeat_at", "desc")
|
||||
->orderBy("id", "desc")
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.machines.index', compact('machines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新機台基本資訊 (目前僅名稱)
|
||||
*/
|
||||
public function update(Request $request, Machine $machine)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$machine->update($validated);
|
||||
|
||||
return redirect()->route('admin.machines.index')
|
||||
->with('success', __('Machine updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示特定機台的日誌與詳細資訊
|
||||
*/
|
||||
public function show(int $id): View
|
||||
{
|
||||
$machine = Machine::with(['logs' => function ($query) {
|
||||
$query->latest()->limit(50);
|
||||
}])->findOrFail($id);
|
||||
$machine = Machine::with([
|
||||
'logs' => function ($query) {
|
||||
$query->latest()->limit(50);
|
||||
}
|
||||
])->findOrFail($id);
|
||||
|
||||
return view('admin.machines.show', compact('machine'));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 顯示所有機台日誌列表
|
||||
* AJAX: 取得機台抽屜面板所需的歷程日誌
|
||||
*/
|
||||
public function logs(Request $request): View
|
||||
public function logsAjax(Request $request, Machine $machine)
|
||||
{
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$logs = \App\Models\Machine\MachineLog::with('machine')
|
||||
$per_page = $request->input('per_page', 20);
|
||||
|
||||
$startDate = $request->get('start_date');
|
||||
$endDate = $request->get('end_date');
|
||||
|
||||
$logs = $machine->logs()
|
||||
->when($request->level, function ($query, $level) {
|
||||
return $query->where('level', $level);
|
||||
})
|
||||
->when($request->machine_id, function ($query, $machineId) {
|
||||
return $query->where('machine_id', $machineId);
|
||||
->when($startDate, function ($query, $start) {
|
||||
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) {
|
||||
return $query->where('type', $type);
|
||||
})
|
||||
->latest()
|
||||
->paginate($per_page)->withQueryString();
|
||||
->paginate($per_page);
|
||||
|
||||
$machines = Machine::select('id', 'name')->get();
|
||||
|
||||
return view('admin.machines.logs', compact('logs', 'machines'));
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $logs->items(),
|
||||
'pagination' => [
|
||||
'total' => $logs->total(),
|
||||
'current_page' => $logs->currentPage(),
|
||||
'last_page' => $logs->lastPage(),
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 機台權限設定 (開發中)
|
||||
*/
|
||||
public function permissions(Request $request): View
|
||||
{
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
}
|
||||
|
||||
/**
|
||||
* 機台使用率統計 (開發中)
|
||||
* 機台使用率統計
|
||||
*/
|
||||
public function utilization(Request $request): View
|
||||
{
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
// 取得當前使用者有權限的所有機台 (已透過 Global Scope 過濾)
|
||||
$machines = Machine::all();
|
||||
|
||||
$date = $request->get('date', now()->toDateString());
|
||||
$service = app(\App\Services\Machine\MachineService::class);
|
||||
$fleetStats = $service->getFleetStats($date);
|
||||
|
||||
return view('admin.machines.utilization', [
|
||||
'machines' => $machines,
|
||||
'fleetStats' => $fleetStats,
|
||||
'compactMachines' => $machines->map(fn($m) => [
|
||||
'id' => $m->id,
|
||||
'name' => $m->name,
|
||||
'serial_no' => $m->serial_no,
|
||||
'status' => $m->status
|
||||
])->values()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 機台到期管理 (開發中)
|
||||
* AJAX: 取得機台所有貨道資訊 (供效期管理視覺化圖表使用)
|
||||
*/
|
||||
public function expiry(Request $request): View
|
||||
public function slotsAjax(Machine $machine)
|
||||
{
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
$slots = $machine->slots()->with('product:id,name,image_url')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'machine' => $machine->only(['id', 'name', 'serial_no']),
|
||||
'slots' => $slots
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 更新貨道資訊 (庫存、效期、批號)
|
||||
*/
|
||||
public function updateSlotExpiry(Request $request, Machine $machine)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'slot_no' => 'required|integer',
|
||||
'stock' => 'nullable|integer|min:0',
|
||||
'expiry_date' => 'nullable|date',
|
||||
'batch_no' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
$this->machineService->updateSlot($machine, $validated, auth()->id());
|
||||
|
||||
session()->flash('success', __('Slot updated successfully.'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Slot updated successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得機台統計數據 (AJAX)
|
||||
*/
|
||||
public function utilizationData(Request $request, $id = null)
|
||||
{
|
||||
$date = $request->get('date', now()->toDateString());
|
||||
$service = app(\App\Services\Machine\MachineService::class);
|
||||
|
||||
if ($id) {
|
||||
$machine = Machine::findOrFail($id);
|
||||
$stats = $service->getUtilizationStats($machine, $date);
|
||||
} else {
|
||||
$stats = $service->getFleetStats($date);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
108
app/Http/Controllers/Admin/MaintenanceController.php
Normal file
108
app/Http/Controllers/Admin/MaintenanceController.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MaintenanceRecord;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MaintenanceController extends Controller
|
||||
{
|
||||
use ImageHandler;
|
||||
|
||||
/**
|
||||
* 維修紀錄列表
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', MaintenanceRecord::class);
|
||||
|
||||
$query = MaintenanceRecord::with(['machine', 'user', 'company'])
|
||||
->whereHas('machine') // 確保僅顯示該帳號「看得見」的機台紀錄,避開因權限隔離導致的 null 報錯
|
||||
->latest('maintenance_at');
|
||||
|
||||
// 搜尋邏輯
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->whereHas('machine', function($q) use ($search) {
|
||||
$q->where('serial_no', 'like', "%{$search}%")
|
||||
->orWhere('name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('category')) {
|
||||
$query->where('category', $request->category);
|
||||
}
|
||||
|
||||
$records = $query->paginate(15)->withQueryString();
|
||||
|
||||
return view('admin.maintenance.index', compact('records'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示新增維修單頁面
|
||||
*/
|
||||
public function create(Request $request, $serial_no = null)
|
||||
{
|
||||
$this->authorize('create', MaintenanceRecord::class);
|
||||
|
||||
$machine = null;
|
||||
if ($serial_no) {
|
||||
$machine = Machine::where('serial_no', $serial_no)->firstOrFail();
|
||||
}
|
||||
|
||||
// 供手動新增時選擇的機台清單 (僅限有權限存取的)
|
||||
$machines = Machine::all();
|
||||
|
||||
return view('admin.maintenance.create', compact('machine', 'machines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存維修單
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$this->authorize('create', MaintenanceRecord::class);
|
||||
|
||||
$validated = $request->validate([
|
||||
'machine_id' => 'required|exists:machines,id',
|
||||
'category' => 'required|in:Repair,Installation,Removal,Maintenance',
|
||||
'content' => 'nullable|string',
|
||||
'maintenance_at' => 'required|date',
|
||||
'photos.*' => 'nullable|image|max:5120', // 每張上限 5MB
|
||||
'is_confirmed' => 'required|accepted',
|
||||
]);
|
||||
|
||||
$machine = Machine::findOrFail($validated['machine_id']);
|
||||
|
||||
$photoPaths = [];
|
||||
if ($request->hasFile('photos')) {
|
||||
foreach ($request->file('photos') as $photo) {
|
||||
if (!$photo) continue;
|
||||
if (count($photoPaths) >= 3) break;
|
||||
|
||||
// 轉為 WebP 格式與保存
|
||||
$path = $this->storeAsWebp($photo, "maintenance/{$machine->id}");
|
||||
$photoPaths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
$record = MaintenanceRecord::create([
|
||||
'company_id' => $machine->company_id, // 從機台帶入歸屬客戶
|
||||
'machine_id' => $machine->id,
|
||||
'user_id' => Auth::id(),
|
||||
'category' => $validated['category'],
|
||||
'content' => $validated['content'],
|
||||
'photos' => $photoPaths,
|
||||
'maintenance_at' => $validated['maintenance_at'],
|
||||
'is_confirmed' => true, // 既然通過驗證(accepted),則存為 true
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.maintenance.index')
|
||||
->with('success', __('Maintenance record created successfully'));
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,9 @@ class PermissionController extends Controller
|
||||
{
|
||||
$per_page = request()->input('per_page', 10);
|
||||
$user = auth()->user();
|
||||
$query = \App\Models\System\Role::query()->with(['permissions', 'users']);
|
||||
$query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
|
||||
|
||||
// 租戶隔離:租戶只能看到自己公司的角色 + 系統角色 (company_id is null)
|
||||
// 租戶隔離:租戶只能看到自己公司的角色
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$query->where('company_id', $user->company_id);
|
||||
}
|
||||
@@ -24,30 +24,97 @@ class PermissionController extends Controller
|
||||
$query->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
// 篩選:公司名稱 (僅限系統管理員)
|
||||
if ($user->isSystemAdmin() && request()->filled('company_id')) {
|
||||
if (request()->company_id === 'system') {
|
||||
$query->whereNull('company_id');
|
||||
} else {
|
||||
$query->where('company_id', request()->company_id);
|
||||
}
|
||||
}
|
||||
|
||||
$roles = $query->latest()->paginate($per_page)->withQueryString();
|
||||
$all_permissions = \Spatie\Permission\Models\Permission::all()
|
||||
->filter(function($perm) {
|
||||
// 排除子項目的權限,只顯示主選單權限
|
||||
$excluded = [
|
||||
'menu.basic.machines',
|
||||
'menu.basic.payment-configs',
|
||||
'menu.companies',
|
||||
'menu.accounts',
|
||||
'menu.roles',
|
||||
];
|
||||
return !in_array($perm->name, $excluded);
|
||||
})
|
||||
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
|
||||
|
||||
// 權限分組邏輯中的標題與過濾
|
||||
$isSubAccountRoles = request()->routeIs('*.sub-account-roles');
|
||||
$title = $isSubAccountRoles ? __('Sub Account Roles') : __('Role Settings');
|
||||
$permissionQuery = \Spatie\Permission\Models\Permission::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
|
||||
}
|
||||
|
||||
// 權限分組邏輯
|
||||
$all_permissions = $permissionQuery->get()
|
||||
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
|
||||
->groupBy(function($perm) {
|
||||
if (str_starts_with($perm->name, 'menu.')) {
|
||||
return 'menu';
|
||||
}
|
||||
return 'other';
|
||||
});
|
||||
|
||||
// 根據路由決定標題
|
||||
$title = request()->routeIs('*.sub-account-roles') ? __('Sub Account Roles') : __('Role Settings');
|
||||
|
||||
return view('admin.permission.roles', compact('roles', 'all_permissions', 'title'));
|
||||
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
|
||||
return view('admin.permission.roles', compact('roles', 'all_permissions', 'title', 'currentUserRoleIds', 'companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new role.
|
||||
*/
|
||||
public function createRole()
|
||||
{
|
||||
$role = new \App\Models\System\Role();
|
||||
$user = auth()->user();
|
||||
|
||||
// 權限遞迴約束
|
||||
$permissionQuery = \Spatie\Permission\Models\Permission::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
|
||||
}
|
||||
|
||||
$all_permissions = $permissionQuery->get()->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other');
|
||||
|
||||
$title = request()->routeIs('*.sub-account-roles.create') ? __('Create Sub Account Role') : __('Create New Role');
|
||||
$back_url = request()->routeIs('*.sub-account-roles.create')
|
||||
? route('admin.data-config.sub-accounts', ['tab' => 'roles'])
|
||||
: route('admin.permission.roles');
|
||||
|
||||
return view('admin.permission.roles-edit', compact('role', 'all_permissions', 'title', 'back_url'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified role.
|
||||
*/
|
||||
public function editRole($id)
|
||||
{
|
||||
$role = \App\Models\System\Role::findOrFail($id);
|
||||
$user = auth()->user();
|
||||
|
||||
// 權限遞迴約束:租戶管理員只能看到並指派自己擁有的權限
|
||||
$permissionQuery = \Spatie\Permission\Models\Permission::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
|
||||
}
|
||||
|
||||
// 權限分組邏輯
|
||||
$all_permissions = $permissionQuery->get()
|
||||
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
|
||||
->groupBy(function($perm) {
|
||||
if (str_starts_with($perm->name, 'menu.')) {
|
||||
return 'menu';
|
||||
}
|
||||
return 'other';
|
||||
});
|
||||
|
||||
// 根據路由決定標題
|
||||
$title = request()->routeIs('*.sub-account-roles.edit') ? __('Edit Sub Account Role') : __('Edit Role Permissions');
|
||||
|
||||
// 麵包屑/返回路徑
|
||||
$back_url = request()->routeIs('*.sub-account-roles.edit')
|
||||
? route('admin.data-config.sub-accounts', ['tab' => 'roles'])
|
||||
: route('admin.permission.roles');
|
||||
|
||||
return view('admin.permission.roles-edit', compact('role', 'all_permissions', 'title', 'back_url'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,6 +145,15 @@ class PermissionController extends Controller
|
||||
|
||||
if (!empty($validated['permissions'])) {
|
||||
$perms = $validated['permissions'];
|
||||
|
||||
// 權限遞迴約束驗證
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$currentUserPerms = auth()->user()->getAllPermissions()->pluck('name');
|
||||
if (collect($perms)->diff($currentUserPerms)->isNotEmpty()) {
|
||||
return redirect()->back()->with('error', __('You cannot assign permissions you do not possess.'));
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不是系統角色,排除主選單的系統權限
|
||||
if (!$is_system) {
|
||||
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']);
|
||||
@@ -85,7 +161,8 @@ class PermissionController extends Controller
|
||||
$role->syncPermissions($perms);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Role created successfully.'));
|
||||
$target_route = request()->routeIs('*.sub-account-roles.*') ? route('admin.data-config.sub-accounts', ['tab' => 'roles']) : route('admin.permission.roles');
|
||||
return redirect()->to($target_route)->with('success', __('Role created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,20 +198,32 @@ class PermissionController extends Controller
|
||||
|
||||
$is_system = auth()->user()->isSystemAdmin() ? $request->boolean('is_system') : $role->is_system;
|
||||
|
||||
$role->update([
|
||||
$updateData = [
|
||||
'name' => $validated['name'],
|
||||
'is_system' => $is_system,
|
||||
'company_id' => $is_system ? null : $role->company_id,
|
||||
]);
|
||||
];
|
||||
|
||||
$role->update($updateData);
|
||||
|
||||
$perms = $validated['permissions'] ?? [];
|
||||
|
||||
// 權限遞迴約束驗證
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$currentUserPerms = auth()->user()->getAllPermissions()->pluck('name');
|
||||
if (collect($perms)->diff($currentUserPerms)->isNotEmpty()) {
|
||||
return redirect()->back()->with('error', __('You cannot assign permissions you do not possess.'));
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不是系統角色,排除主選單的系統權限
|
||||
if (!$is_system) {
|
||||
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']);
|
||||
}
|
||||
$role->syncPermissions($perms);
|
||||
|
||||
return redirect()->back()->with('success', __('Role updated successfully.'));
|
||||
$target_route = request()->routeIs('*.sub-account-roles.*') ? route('admin.data-config.sub-accounts', ['tab' => 'roles']) : route('admin.permission.roles');
|
||||
return redirect()->to($target_route)->with('success', __('Role updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,46 +247,99 @@ class PermissionController extends Controller
|
||||
|
||||
$role->delete();
|
||||
|
||||
if (request()->routeIs('*.sub-account-roles.*')) {
|
||||
return redirect()->route('admin.data-config.sub-accounts', ['tab' => 'roles'])->with('success', __('Role deleted successfully.'));
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Role deleted successfully.'));
|
||||
}
|
||||
|
||||
// 帳號管理
|
||||
public function accounts(Request $request)
|
||||
{
|
||||
$query = \App\Models\System\User::query()->with(['company', 'roles']);
|
||||
$user = auth()->user();
|
||||
$isSubAccountRoute = $request->routeIs('admin.data-config.sub-accounts');
|
||||
$tab = $request->input('tab', 'accounts');
|
||||
|
||||
// 租戶隔離:如果不是系統管理員,則只看自己公司的成員
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$query->where('company_id', auth()->user()->company_id);
|
||||
// 初始化變數
|
||||
$users = collect();
|
||||
$roles = collect();
|
||||
$paginated_roles = collect();
|
||||
$all_permissions = collect();
|
||||
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
|
||||
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
|
||||
|
||||
if ($isSubAccountRoute && $tab === 'roles') {
|
||||
// 處理角色分頁邏輯 (移植自 roles())
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$roles_query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
|
||||
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$roles_query->where('company_id', $user->company_id);
|
||||
}
|
||||
|
||||
if ($search = $request->input('search')) {
|
||||
$roles_query->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
if ($user->isSystemAdmin() && $request->filled('company_id')) {
|
||||
if ($request->company_id === 'system') {
|
||||
$roles_query->where('is_system', true);
|
||||
} else {
|
||||
$roles_query->where('company_id', $request->company_id);
|
||||
}
|
||||
}
|
||||
|
||||
$paginated_roles = $roles_query->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
// 權限分組邏輯
|
||||
$permissionQuery = \Spatie\Permission\Models\Permission::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
|
||||
}
|
||||
|
||||
$all_permissions = $permissionQuery->get()
|
||||
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
|
||||
->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other');
|
||||
} else {
|
||||
// 處理帳號名單邏輯
|
||||
$query = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
|
||||
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$query->where('company_id', $user->company_id);
|
||||
}
|
||||
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($user->isSystemAdmin() && $request->filled('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);
|
||||
$users = $query->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
$roles_query = \App\Models\System\Role::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$roles_query->forCompany($user->company_id);
|
||||
}
|
||||
$roles = $roles_query->get();
|
||||
}
|
||||
|
||||
// 搜尋
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
$title = $isSubAccountRoute ? __('Sub Account Management') : __('Account Management');
|
||||
|
||||
// 公司篩選 (僅限 super-admin)
|
||||
if (auth()->user()->isSystemAdmin() && $request->filled('company_id')) {
|
||||
$query->where('company_id', $request->company_id);
|
||||
}
|
||||
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$users = $query->latest()->paginate($per_page)->withQueryString();
|
||||
$companies = auth()->user()->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
|
||||
$roles_query = \App\Models\System\Role::where('name', '!=', 'super-admin');
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$roles_query->where('company_id', auth()->user()->company_id);
|
||||
}
|
||||
$roles = $roles_query->get();
|
||||
|
||||
// 根據路由決定標題
|
||||
$title = request()->routeIs('*.sub-accounts') ? __('Sub Account Management') : __('Account Management');
|
||||
|
||||
return view('admin.data-config.accounts', compact('users', 'companies', 'roles', 'title'));
|
||||
return view('admin.data-config.accounts', compact(
|
||||
'users', 'companies', 'roles', 'paginated_roles', 'all_permissions', 'title', 'tab', 'currentUserRoleIds'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,7 +350,7 @@ class PermissionController extends Controller
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'username' => 'required|string|max:255|unique:users,username',
|
||||
'email' => 'required|email|max:255|unique:users,email',
|
||||
'email' => 'nullable|email|max:255|unique:users,email',
|
||||
'password' => 'required|string|min:8',
|
||||
'role' => 'required|string',
|
||||
'status' => 'required|boolean',
|
||||
@@ -231,26 +373,25 @@ class PermissionController extends Controller
|
||||
|
||||
// 驗證角色與公司的匹配性 (RBAC Safeguard)
|
||||
if ($company_id !== null) {
|
||||
// 如果是租戶帳號,不能選各項系統角色 (is_system = 1)
|
||||
if ($role->is_system) {
|
||||
return redirect()->back()->with('error', __('System roles cannot be assigned to tenant accounts.'));
|
||||
// 如果是租戶帳號,絕對不能指派超級管理員角色 (super-admin)
|
||||
if ($role->name === 'super-admin') {
|
||||
return redirect()->back()->with('error', __('Super-admin role cannot be assigned to tenant accounts.'));
|
||||
}
|
||||
// 如果角色有特定的 company_id,必須匹配
|
||||
if ($role->company_id !== null && $role->company_id != $company_id) {
|
||||
return redirect()->back()->with('error', __('This role belongs to another company and cannot be assigned.'));
|
||||
}
|
||||
} else {
|
||||
// 如果是系統層級帳號,只能選系統角色 (is_system = 1)
|
||||
// 如果是系統層級帳號,只能選全域系統角色 (is_system = 1)
|
||||
if (!$role->is_system) {
|
||||
return redirect()->back()->with('error', __('Only system roles can be assigned to platform administrative accounts.'));
|
||||
}
|
||||
}
|
||||
|
||||
// 角色初始化與克隆邏輯 (只有 super-admin 在幫空白公司開帳號時觸發)
|
||||
$role_to_assign = $validated['role'];
|
||||
$company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
|
||||
|
||||
if ($company_id && $role && !$role->is_system && $role->company_id === null) {
|
||||
if ($company_id && $role && $role->company_id === null && $role->name !== 'super-admin') {
|
||||
// 檢查該公司是否已有名為「管理員」的角色
|
||||
$existingRole = \App\Models\System\Role::where('company_id', $company_id)
|
||||
->where('name', '管理員')
|
||||
@@ -258,17 +399,17 @@ class PermissionController extends Controller
|
||||
|
||||
if (!$existingRole) {
|
||||
// 克隆範本為該公司的「管理員」
|
||||
$clonedRole = \App\Models\System\Role::query()->create([
|
||||
$newRole = \App\Models\System\Role::query()->create([
|
||||
'name' => '管理員',
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $company_id,
|
||||
'is_system' => false,
|
||||
]);
|
||||
$clonedRole->syncPermissions($role->permissions);
|
||||
$role_to_assign = '管理員';
|
||||
$newRole->syncPermissions($role->getPermissionNames());
|
||||
$role = $newRole;
|
||||
} else {
|
||||
// 如果已存在名為「管理員」的角色,則直接使用它
|
||||
$role_to_assign = '管理員';
|
||||
$role = $existingRole;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,10 +420,11 @@ class PermissionController extends Controller
|
||||
'password' => \Illuminate\Support\Facades\Hash::make($validated['password']),
|
||||
'status' => $validated['status'],
|
||||
'company_id' => $company_id,
|
||||
'phone' => $validated['phone'],
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'is_admin' => (auth()->user()->isSystemAdmin() && !empty($validated['company_id'])),
|
||||
]);
|
||||
|
||||
$user->assignRole($role_to_assign);
|
||||
$user->assignRole($role);
|
||||
|
||||
return redirect()->back()->with('success', __('Account created successfully.'));
|
||||
}
|
||||
@@ -294,14 +436,14 @@ class PermissionController extends Controller
|
||||
{
|
||||
$user = \App\Models\System\User::findOrFail($id);
|
||||
|
||||
if ($user->hasRole('super-admin')) {
|
||||
return redirect()->back()->with('error', __('System super admin accounts cannot be modified via this interface.'));
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
return redirect()->back()->with('error', __('System super admin accounts can only be modified by other super admins.'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'username' => 'required|string|max:255|unique:users,username,' . $id,
|
||||
'email' => 'required|email|max:255|unique:users,email,' . $id,
|
||||
'email' => 'nullable|email|max:255|unique:users,email,' . $id,
|
||||
'password' => 'nullable|string|min:8',
|
||||
'role' => 'required|string',
|
||||
'status' => 'required|boolean',
|
||||
@@ -325,15 +467,16 @@ class PermissionController extends Controller
|
||||
// 驗證角色與公司的匹配性 (RBAC Safeguard)
|
||||
if ($user->id !== auth()->id()) { // 排除編輯自己 (super-admin 有特殊邏輯)
|
||||
if ($target_company_id !== null) {
|
||||
if ($roleObj->is_system) {
|
||||
return redirect()->back()->with('error', __('System roles cannot be assigned to tenant accounts.'));
|
||||
// 租戶層級排除 super-admin
|
||||
if ($roleObj->name === 'super-admin') {
|
||||
return redirect()->back()->with('error', __('Super-admin role cannot be assigned to tenant accounts.'));
|
||||
}
|
||||
if ($roleObj->company_id !== null && $roleObj->company_id != $target_company_id) {
|
||||
return redirect()->back()->with('error', __('This role belongs to another company and cannot be assigned.'));
|
||||
}
|
||||
} else {
|
||||
if (!$roleObj->is_system) {
|
||||
return redirect()->back()->with('error', __('Only system roles can be assigned to platform administrative accounts.'));
|
||||
return redirect()->back()->with('error', __('Only global system roles can be assigned to platform administrative accounts.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,9 +486,21 @@ class PermissionController extends Controller
|
||||
'username' => $validated['username'],
|
||||
'email' => $validated['email'],
|
||||
'status' => $validated['status'],
|
||||
'phone' => $validated['phone'],
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
];
|
||||
|
||||
// 只有系統管理員在編輯租戶帳號時,且該帳號原本不是管理員,才可能觸發標記(視需求而定)
|
||||
// 這裡我們維持 storeAccount 的邏輯:如果是系統管理員幫公司「開站」或「首配」,才自動標記
|
||||
// 為求嚴謹,我們檢查該公司是否已經有 is_admin,如果沒有,當前這個人可以是第一個
|
||||
if (auth()->user()->isSystemAdmin() && !empty($validated['company_id']) && !$user->is_admin) {
|
||||
$hasAdmin = \App\Models\System\User::where('company_id', $validated['company_id'])
|
||||
->where('is_admin', true)
|
||||
->exists();
|
||||
if (!$hasAdmin) {
|
||||
$updateData['is_admin'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (auth()->user()->isSystemAdmin()) {
|
||||
// 防止超級管理員不小心把自己綁定到租客公司或降級
|
||||
if ($user->id === auth()->id()) {
|
||||
@@ -361,26 +516,26 @@ class PermissionController extends Controller
|
||||
}
|
||||
|
||||
// 角色初始化與克隆邏輯
|
||||
$role_to_assign = $validated['role'];
|
||||
$target_company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
|
||||
|
||||
if ($target_company_id && $roleObj && !$roleObj->is_system && $roleObj->company_id === null) {
|
||||
if ($target_company_id && $roleObj && $roleObj->company_id === null && $roleObj->name !== 'super-admin') {
|
||||
// 檢查該公司是否已有名為「管理員」的角色
|
||||
$existingRole = \App\Models\System\Role::where('company_id', $target_company_id)
|
||||
->where('name', '管理員')
|
||||
->first();
|
||||
|
||||
if (!$existingRole) {
|
||||
$clonedRole = \App\Models\System\Role::query()->create([
|
||||
$newRole = \App\Models\System\Role::query()->create([
|
||||
'name' => '管理員',
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $target_company_id,
|
||||
'is_system' => false,
|
||||
'is_admin' => true,
|
||||
]);
|
||||
$clonedRole->syncPermissions($roleObj->permissions);
|
||||
$role_to_assign = '管理員';
|
||||
$newRole->syncPermissions($roleObj->getPermissionNames());
|
||||
$roleObj = $newRole;
|
||||
} else {
|
||||
$role_to_assign = '管理員';
|
||||
$roleObj = $existingRole;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +545,7 @@ class PermissionController extends Controller
|
||||
if ($user->id === auth()->id() && auth()->user()->isSystemAdmin()) {
|
||||
$user->syncRoles(['super-admin']);
|
||||
} else {
|
||||
$user->syncRoles([$role_to_assign]);
|
||||
$user->syncRoles([$roleObj]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Account updated successfully.'));
|
||||
@@ -403,8 +558,8 @@ class PermissionController extends Controller
|
||||
{
|
||||
$user = \App\Models\System\User::findOrFail($id);
|
||||
|
||||
if ($user->hasRole('super-admin')) {
|
||||
return redirect()->back()->with('error', __('System super admin accounts cannot be deleted.'));
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
return redirect()->back()->with('error', __('System super admin accounts can only be deleted by other super admins.'));
|
||||
}
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
@@ -421,4 +576,20 @@ class PermissionController extends Controller
|
||||
|
||||
return redirect()->back()->with('success', __('Account deleted successfully.'));
|
||||
}
|
||||
|
||||
public function toggleAccountStatus($id)
|
||||
{
|
||||
$user = \App\Models\System\User::findOrFail($id);
|
||||
|
||||
// 非超級管理員禁止切換 Super Admin 狀態
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
return back()->with('error', __('Only Super Admins can change other Super Admin status.'));
|
||||
}
|
||||
|
||||
$user->status = $user->status ? 0 : 1;
|
||||
$user->save();
|
||||
|
||||
$statusText = $user->status ? __('Enabled') : __('Disabled');
|
||||
return back()->with('success', __('Account :name status has been changed to :status.', ['name' => $user->name, 'status' => $statusText]));
|
||||
}
|
||||
}
|
||||
|
||||
167
app/Http/Controllers/Admin/ProductCategoryController.php
Normal file
167
app/Http/Controllers/Admin/ProductCategoryController.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product\ProductCategory;
|
||||
use App\Models\System\Translation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProductCategoryController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示商品分類清單 (主要用於 AJAX 或內嵌在商品管理頁面)
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$query = ProductCategory::with(['translations']);
|
||||
|
||||
if ($user->isSystemAdmin() && $request->filled('company_id')) {
|
||||
$query->where('company_id', $request->company_id);
|
||||
}
|
||||
|
||||
$categories = $query->latest()->get();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $categories
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.data-config.products.index', ['tab' => 'categories']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存新分類
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'names.zh_TW' => 'required|string|max:255',
|
||||
'names.en' => 'nullable|string|max:255',
|
||||
'names.ja' => 'nullable|string|max:255',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$dictKey = Str::uuid()->toString();
|
||||
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
|
||||
? $request->company_id
|
||||
: auth()->user()->company_id;
|
||||
|
||||
// 儲存多語系翻譯
|
||||
foreach ($request->names as $locale => $value) {
|
||||
if (empty($value)) continue;
|
||||
Translation::withoutGlobalScopes()->create([
|
||||
'group' => 'category',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale,
|
||||
'value' => $value,
|
||||
'company_id' => $company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
$category = ProductCategory::create([
|
||||
'company_id' => $company_id,
|
||||
'name' => $request->names['zh_TW'] ?? (collect($request->names)->first() ?? 'Untitled'),
|
||||
'name_dictionary_key' => $dictKey,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()->back()->with('success', __('Category created successfully'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return redirect()->back()->with('error', $e->getMessage())->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分類
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$category = ProductCategory::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'names.zh_TW' => 'required|string|max:255',
|
||||
'names.en' => 'nullable|string|max:255',
|
||||
'names.ja' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$dictKey = $category->name_dictionary_key ?: Str::uuid()->toString();
|
||||
$company_id = $category->company_id;
|
||||
|
||||
foreach ($request->names as $locale => $value) {
|
||||
if (empty($value)) {
|
||||
Translation::withoutGlobalScopes()->where([
|
||||
'group' => 'category',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale
|
||||
])->delete();
|
||||
continue;
|
||||
}
|
||||
|
||||
Translation::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'group' => 'category',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale,
|
||||
],
|
||||
[
|
||||
'value' => $value,
|
||||
'company_id' => $company_id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$category->update([
|
||||
'name' => $request->names['zh_TW'] ?? $category->name,
|
||||
'name_dictionary_key' => $dictKey,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()->back()->with('success', __('Category updated successfully'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return redirect()->back()->with('error', $e->getMessage())->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除分類
|
||||
*/
|
||||
public function destroy($id)
|
||||
{
|
||||
try {
|
||||
$category = ProductCategory::findOrFail($id);
|
||||
|
||||
// 檢查是否已有商品使用此分類
|
||||
if ($category->products()->count() > 0) {
|
||||
return redirect()->back()->with('error', __('Cannot delete category that has products. Please move products first.'));
|
||||
}
|
||||
|
||||
if ($category->name_dictionary_key) {
|
||||
Translation::withoutGlobalScopes()->where('group', 'category')->where('key', $category->name_dictionary_key)->delete();
|
||||
}
|
||||
|
||||
$category->delete();
|
||||
|
||||
return redirect()->back()->with('success', __('Category deleted successfully'));
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
345
app/Http/Controllers/Admin/ProductController.php
Normal file
345
app/Http/Controllers/Admin/ProductController.php
Normal file
@@ -0,0 +1,345 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product\Product;
|
||||
use App\Models\Product\ProductCategory;
|
||||
use App\Models\System\Company;
|
||||
use App\Models\System\Translation;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
use \App\Traits\ImageHandler;
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$query = Product::with(['category.translations', 'translations', 'company']);
|
||||
|
||||
// 搜尋
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('barcode', 'like', "%{$search}%")
|
||||
->orWhere('spec', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 分類篩選
|
||||
if ($request->filled('category_id')) {
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
$per_page = $request->input('per_page', 10);
|
||||
|
||||
$companyId = $user->company_id;
|
||||
if ($user->isSystemAdmin()) {
|
||||
if ($request->filled('company_id')) {
|
||||
$companyId = $request->company_id;
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
}
|
||||
|
||||
$products = $query->latest()->paginate($per_page)->withQueryString();
|
||||
$categories = ProductCategory::with('translations')->get();
|
||||
$companies = $user->isSystemAdmin() ? Company::all() : collect();
|
||||
|
||||
// 系統管理員在過濾特定公司時,應顯示該公司的功能開關 (如物料代碼、點數規則)
|
||||
$selectedCompany = $companyId ? Company::find($companyId) : $user->company;
|
||||
$companySettings = $selectedCompany ? ($selectedCompany->settings ?? []) : [];
|
||||
|
||||
$routeName = 'admin.data-config.products.index';
|
||||
|
||||
return view('admin.products.index', [
|
||||
'products' => $products,
|
||||
'categories' => $categories,
|
||||
'companies' => $companies,
|
||||
'companySettings' => $companySettings,
|
||||
'routeName' => $routeName
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$categories = ProductCategory::with('translations')->get();
|
||||
$companies = $user->isSystemAdmin() ? Company::all() : collect();
|
||||
|
||||
// If system admin, check if company_id is provided in URL to get settings
|
||||
$companyId = $request->query('company_id') ?? $user->company_id;
|
||||
$selectedCompany = $companyId ? Company::find($companyId) : $user->company;
|
||||
$companySettings = $selectedCompany ? ($selectedCompany->settings ?? []) : [];
|
||||
|
||||
return view('admin.products.create', [
|
||||
'categories' => $categories,
|
||||
'companies' => $companies,
|
||||
'companySettings' => $companySettings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$user = auth()->user();
|
||||
// 繞過 TenantScoped 載入翻譯,確保系統管理員能看到租戶公司的翻譯資料
|
||||
$product = Product::with(['company'])->findOrFail($id);
|
||||
$product->setRelation('translations',
|
||||
Translation::withoutGlobalScopes()
|
||||
->where('group', 'product')
|
||||
->where('key', $product->name_dictionary_key)
|
||||
->get()
|
||||
);
|
||||
$categories = ProductCategory::with('translations')->get();
|
||||
$companies = $user->isSystemAdmin() ? Company::all() : collect();
|
||||
|
||||
// Use the product's company settings for editing
|
||||
$companySettings = $product->company ? ($product->company->settings ?? []) : [];
|
||||
|
||||
return view('admin.products.edit', [
|
||||
'product' => $product,
|
||||
'categories' => $categories,
|
||||
'companies' => $companies,
|
||||
'companySettings' => $companySettings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'names.zh_TW' => 'required|string|max:255',
|
||||
'names.en' => 'nullable|string|max:255',
|
||||
'names.ja' => 'nullable|string|max:255',
|
||||
'barcode' => 'nullable|string|max:100',
|
||||
'spec' => 'nullable|string|max:255',
|
||||
'category_id' => 'nullable|exists:product_categories,id',
|
||||
'manufacturer' => 'nullable|string|max:255',
|
||||
'track_limit' => 'required|integer|min:1',
|
||||
'spring_limit' => 'required|integer|min:1',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'cost' => 'required|numeric|min:0',
|
||||
'member_price' => 'required|numeric|min:0',
|
||||
'metadata' => 'nullable|array',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$dictKey = \Illuminate\Support\Str::uuid()->toString();
|
||||
// Determine company_id: prioritized from request (for sys admin) then from user
|
||||
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
|
||||
? $request->company_id
|
||||
: auth()->user()->company_id;
|
||||
|
||||
// 儲存多語系翻譯(繞過 TenantScoped,避免系統管理員操作租戶資料時被過濾)
|
||||
foreach ($request->names as $locale => $name) {
|
||||
if (empty($name)) continue;
|
||||
Translation::withoutGlobalScopes()->create([
|
||||
'group' => 'product',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale,
|
||||
'value' => $name,
|
||||
'company_id' => $company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
$imageUrl = null;
|
||||
if ($request->hasFile('image')) {
|
||||
$path = $this->storeAsWebp($request->file('image'), 'products');
|
||||
$imageUrl = Storage::url($path);
|
||||
}
|
||||
|
||||
$product = Product::create([
|
||||
'company_id' => $company_id,
|
||||
'category_id' => $request->category_id,
|
||||
'name' => $request->names['zh_TW'] ?? (collect($request->names)->first() ?? 'Untitled'), // Fallback if zh_TW is missing
|
||||
'name_dictionary_key' => $dictKey,
|
||||
'image_url' => $imageUrl,
|
||||
'barcode' => $request->barcode,
|
||||
'spec' => $request->spec,
|
||||
'manufacturer' => $request->manufacturer,
|
||||
'track_limit' => $request->track_limit,
|
||||
'spring_limit' => $request->spring_limit,
|
||||
'price' => $request->price,
|
||||
'cost' => $request->cost,
|
||||
'member_price' => $request->member_price,
|
||||
'metadata' => $request->metadata ?? [],
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Product created successfully'),
|
||||
'data' => $product
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.data-config.products.index')->with('success', __('Product created successfully'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
return redirect()->back()->with('error', $e->getMessage())->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$product = Product::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'names.zh_TW' => 'required|string|max:255',
|
||||
'names.en' => 'nullable|string|max:255',
|
||||
'names.ja' => 'nullable|string|max:255',
|
||||
'barcode' => 'nullable|string|max:100',
|
||||
'spec' => 'nullable|string|max:255',
|
||||
'category_id' => 'nullable|exists:product_categories,id',
|
||||
'manufacturer' => 'nullable|string|max:255',
|
||||
'track_limit' => 'required|integer|min:1',
|
||||
'spring_limit' => 'required|integer|min:1',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'cost' => 'required|numeric|min:0',
|
||||
'member_price' => 'required|numeric|min:0',
|
||||
'metadata' => 'nullable|array',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB
|
||||
'remove_image' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$dictKey = $product->name_dictionary_key ?: \Illuminate\Support\Str::uuid()->toString();
|
||||
$company_id = $product->company_id;
|
||||
|
||||
// 更新或建立多語系翻譯(繞過 TenantScoped,避免系統管理員操作租戶資料時被過濾)
|
||||
foreach ($request->names as $locale => $name) {
|
||||
if (empty($name)) {
|
||||
Translation::withoutGlobalScopes()->where([
|
||||
'group' => 'product',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale
|
||||
])->delete();
|
||||
continue;
|
||||
}
|
||||
|
||||
Translation::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'group' => 'product',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale,
|
||||
],
|
||||
[
|
||||
'value' => $name,
|
||||
'company_id' => $company_id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'category_id' => $request->category_id,
|
||||
'name' => $request->names['zh_TW'] ?? ($product->name ?? 'Untitled'),
|
||||
'name_dictionary_key' => $dictKey,
|
||||
'barcode' => $request->barcode,
|
||||
'spec' => $request->spec,
|
||||
'manufacturer' => $request->manufacturer,
|
||||
'track_limit' => $request->track_limit,
|
||||
'spring_limit' => $request->spring_limit,
|
||||
'price' => $request->price,
|
||||
'cost' => $request->cost,
|
||||
'member_price' => $request->member_price,
|
||||
'metadata' => $request->metadata ?? [],
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
];
|
||||
|
||||
if ($request->hasFile('image')) {
|
||||
// Delete old image
|
||||
if ($product->image_url) {
|
||||
$oldPath = str_replace('/storage/', '', $product->image_url);
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
$path = $this->storeAsWebp($request->file('image'), 'products');
|
||||
$data['image_url'] = Storage::url($path);
|
||||
} elseif ($request->boolean('remove_image')) {
|
||||
if ($product->image_url) {
|
||||
$oldPath = str_replace('/storage/', '', $product->image_url);
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
$data['image_url'] = null;
|
||||
}
|
||||
|
||||
$product->update($data);
|
||||
|
||||
DB::commit();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Product updated successfully'),
|
||||
'data' => $product
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.data-config.products.index')->with('success', __('Product updated successfully'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
return redirect()->back()->with('error', $e->getMessage())->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleStatus($id)
|
||||
{
|
||||
try {
|
||||
$product = Product::findOrFail($id);
|
||||
$product->is_active = !$product->is_active;
|
||||
$product->save();
|
||||
|
||||
$status = $product->is_active ? __('Enabled') : __('Disabled');
|
||||
return redirect()->back()->with('success', __('Product status updated to :status', ['status' => $status]));
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
try {
|
||||
$product = Product::findOrFail($id);
|
||||
|
||||
// 刪除與此商品關聯的翻譯資料(繞過 TenantScoped)
|
||||
if ($product->name_dictionary_key) {
|
||||
Translation::withoutGlobalScopes()->where('key', $product->name_dictionary_key)->delete();
|
||||
}
|
||||
|
||||
// Delete image
|
||||
if ($product->image_url) {
|
||||
$oldPath = str_replace('/storage/', '', $product->image_url);
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
|
||||
$product->delete();
|
||||
|
||||
return redirect()->back()->with('success', __('Product deleted successfully'));
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/Http/Controllers/Admin/QrCodeController.php
Normal file
34
app/Http/Controllers/Admin/QrCodeController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
|
||||
class QrCodeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Generate a QR Code image.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function generate(Request $request)
|
||||
{
|
||||
$data = $request->query('data');
|
||||
$size = $request->query('size', 250);
|
||||
|
||||
if (!$data) {
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
// Generate SVG QR Code
|
||||
$qrCode = QrCode::size($size)
|
||||
->format('svg')
|
||||
->margin(1)
|
||||
->generate($data);
|
||||
|
||||
return response($qrCode)->header('Content-Type', 'image/svg+xml');
|
||||
}
|
||||
}
|
||||
@@ -4,69 +4,131 @@ namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\RemoteCommand;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class RemoteController extends Controller
|
||||
{
|
||||
// 機台庫存
|
||||
public function stock()
|
||||
/**
|
||||
* 遠端管理指揮中心
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端修改機台庫存',
|
||||
'description' => '遠端修改機台庫存數量',
|
||||
$machines = Machine::withCount(['slots'])->orderBy('last_heartbeat_at', 'desc')->orderBy('id', 'desc')->get();
|
||||
$selectedMachine = null;
|
||||
$history = RemoteCommand::where('command_type', '!=', 'reload_stock')->with(['machine', 'user'])->latest()->limit(50)->get();
|
||||
|
||||
if ($request->has('machine_id')) {
|
||||
$selectedMachine = Machine::with(['slots.product', 'commands' => function($query) {
|
||||
$query->where('command_type', '!=', 'reload_stock')
|
||||
->latest()
|
||||
->limit(5);
|
||||
}])->find($request->machine_id);
|
||||
}
|
||||
|
||||
if ($request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'machine' => $selectedMachine,
|
||||
'commands' => $selectedMachine ? $selectedMachine->commands : []
|
||||
]);
|
||||
}
|
||||
|
||||
return view('admin.remote.index', [
|
||||
'machines' => $machines,
|
||||
'selectedMachine' => $selectedMachine,
|
||||
'history' => $history,
|
||||
'title' => __('Remote Command Center'),
|
||||
'subtitle' => __('Execute maintenance and operational commands remotely')
|
||||
]);
|
||||
}
|
||||
|
||||
// 機台重啟
|
||||
public function restart()
|
||||
/**
|
||||
* 儲存遠端指令
|
||||
*/
|
||||
public function storeCommand(Request $request)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端重啟機台',
|
||||
'description' => '遠端重啟機台系統',
|
||||
$validated = $request->validate([
|
||||
'machine_id' => 'required|exists:machines,id',
|
||||
'command_type' => 'required|string|in:reboot,reboot_card,checkout,lock,unlock,change,dispense',
|
||||
'amount' => 'nullable|integer|min:0',
|
||||
'slot_no' => 'nullable|string',
|
||||
'note' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$payload = [];
|
||||
if ($validated['command_type'] === 'change') {
|
||||
$payload['amount'] = $validated['amount'];
|
||||
} elseif ($validated['command_type'] === 'dispense') {
|
||||
$payload['slot_no'] = $validated['slot_no'];
|
||||
}
|
||||
|
||||
// 指令去重:將同機台、同類型的 pending 指令標記為「已取代」
|
||||
RemoteCommand::where('machine_id', $validated['machine_id'])
|
||||
->where('command_type', $validated['command_type'])
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'superseded',
|
||||
'note' => __('Superseded by new command'),
|
||||
'executed_at' => now(),
|
||||
]);
|
||||
|
||||
RemoteCommand::create([
|
||||
'machine_id' => $validated['machine_id'],
|
||||
'user_id' => auth()->id(),
|
||||
'command_type' => $validated['command_type'],
|
||||
'payload' => $payload,
|
||||
'status' => 'pending',
|
||||
'note' => $validated['note'] ?? null,
|
||||
]);
|
||||
|
||||
session()->flash('success', __('Command has been queued successfully.'));
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Command has been queued successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// 卡機重啟
|
||||
public function restartCardReader()
|
||||
/**
|
||||
* 機台庫存管理 (現有功能保留)
|
||||
*/
|
||||
public function stock(Request $request)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端重啟刷卡機',
|
||||
'description' => '遠端重啟刷卡機設備',
|
||||
]);
|
||||
}
|
||||
$machines = Machine::withCount([
|
||||
'slots as slots_count',
|
||||
'slots as low_stock_count' => function ($query) {
|
||||
$query->where('stock', '<=', 5);
|
||||
},
|
||||
'slots as expiring_soon_count' => function ($query) {
|
||||
$query->whereNotNull('expiry_date')
|
||||
->where('expiry_date', '<=', now()->addDays(7))
|
||||
->where('expiry_date', '>=', now()->startOfDay());
|
||||
}
|
||||
])->orderBy('last_heartbeat_at', 'desc')->orderBy('id', 'desc')->get();
|
||||
|
||||
$history = RemoteCommand::where('command_type', 'reload_stock')->with(['machine', 'user'])->latest()->limit(50)->get();
|
||||
|
||||
// 遠端結帳
|
||||
public function checkout()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端結帳',
|
||||
'description' => '遠端執行結帳流程',
|
||||
]);
|
||||
}
|
||||
$selectedMachine = null;
|
||||
if ($request->has('machine_id')) {
|
||||
$selectedMachine = Machine::with(['slots.product', 'commands' => function($query) {
|
||||
$query->where('command_type', 'reload_stock')
|
||||
->latest()
|
||||
->limit(50);
|
||||
}])->find($request->machine_id);
|
||||
}
|
||||
|
||||
// 遠端鎖定頁
|
||||
public function lock()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端鎖定頁',
|
||||
'description' => '遠端鎖定機台頁面',
|
||||
]);
|
||||
}
|
||||
|
||||
// 遠端找零
|
||||
public function change()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端找零',
|
||||
'description' => '遠端執行找零功能',
|
||||
]);
|
||||
}
|
||||
|
||||
// 遠端出貨
|
||||
public function dispense()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端出貨',
|
||||
'description' => '遠端控制商品出貨',
|
||||
return view('admin.remote.stock', [
|
||||
'machines' => $machines,
|
||||
'selectedMachine' => $selectedMachine,
|
||||
'history' => $history,
|
||||
'title' => __('Stock & Expiry Management'),
|
||||
'subtitle' => __('Real-time monitoring and adjustment of cargo lane inventory and expiration dates')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
119
app/Http/Controllers/Api/V1/App/MachineAuthController.php
Normal file
119
app/Http/Controllers/Api/V1/App/MachineAuthController.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Models\System\User;
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Jobs\Machine\ProcessStateLog;
|
||||
|
||||
class MachineAuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* B000: 機台本機管理員/補貨員 離線同步登入驗證
|
||||
* 這個 API 僅用於機台的 Java App 登入畫面驗證帳密。不必進入 Queue。
|
||||
*/
|
||||
public function loginB000(Request $request): JsonResponse
|
||||
{
|
||||
// 1. 驗證欄位 (相容舊版 Java App 發送的 JSON 格式)
|
||||
$validated = $request->validate([
|
||||
'machine' => 'required|string',
|
||||
'Su_Account' => 'required|string',
|
||||
'Su_Password' => 'required|string',
|
||||
'ip' => 'nullable|string',
|
||||
'type' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// 2. 取得機台物件 (需優先於帳密驗證,以便記錄日誌到正確機台)
|
||||
$machine = Machine::withoutGlobalScopes()->where('serial_no', $validated['machine'])->first();
|
||||
|
||||
if (!$machine) {
|
||||
Log::warning("B000 機台登入失敗: 伺服器找不到該機台", [
|
||||
'machine_serial' => $validated['machine']
|
||||
]);
|
||||
return response()->json(['message' => 'Failed']);
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
$isAuthorized = true;
|
||||
} elseif ($user->is_admin) {
|
||||
if ($machine->company_id === $user->company_id) {
|
||||
$isAuthorized = true;
|
||||
}
|
||||
} else {
|
||||
if ($user->machines()->where('machine_id', $machine->id)->exists()) {
|
||||
$isAuthorized = true;
|
||||
}
|
||||
}
|
||||
|
||||
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 機台登入成功", [
|
||||
'account' => $user->username,
|
||||
'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([
|
||||
'message' => 'Success',
|
||||
'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,15 @@ namespace App\Http\Controllers\Api\V1\App;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\System\User;
|
||||
use App\Jobs\Machine\ProcessHeartbeat;
|
||||
use App\Jobs\Machine\ProcessTimerStatus;
|
||||
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\Cache;
|
||||
|
||||
class MachineController extends Controller
|
||||
{
|
||||
@@ -18,50 +23,198 @@ class MachineController extends Controller
|
||||
public function heartbeat(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$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);
|
||||
|
||||
// 取出待處理指令
|
||||
$command = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||||
->pending()
|
||||
->first();
|
||||
|
||||
$status = '49'; // 預設 49 (OK / No Command)
|
||||
$message = 'OK';
|
||||
|
||||
if ($command) {
|
||||
switch ($command->command_type) {
|
||||
case 'reboot':
|
||||
$status = '51';
|
||||
$message = 'reboot';
|
||||
break;
|
||||
case 'reboot_card':
|
||||
$status = '60';
|
||||
$message = 'reboot card machine';
|
||||
break;
|
||||
case 'checkout':
|
||||
$status = '61';
|
||||
$message = 'checkout';
|
||||
break;
|
||||
case 'lock':
|
||||
$status = '71';
|
||||
$message = 'lock';
|
||||
break;
|
||||
case 'unlock':
|
||||
$status = '70';
|
||||
$message = 'unlock';
|
||||
break;
|
||||
case 'change':
|
||||
$status = '82';
|
||||
$message = $command->payload['amount'] ?? '0';
|
||||
break;
|
||||
case 'dispense':
|
||||
$status = '85';
|
||||
$message = $command->payload['slot_no'] ?? '';
|
||||
break;
|
||||
case 'reload_stock':
|
||||
$status = '49';
|
||||
$message = 'reload B017';
|
||||
break;
|
||||
}
|
||||
// 標記為已發送 (sent)
|
||||
$command->update(['status' => 'sent', 'executed_at' => now()]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'OK',
|
||||
'status' => '49' // 某些硬體可能需要的成功碼
|
||||
'message' => $message,
|
||||
'status' => $status
|
||||
], 202); // 202 Accepted
|
||||
}
|
||||
|
||||
/**
|
||||
* B018: Record Machine Restock/Setup Report (Asynchronous)
|
||||
*/
|
||||
public function recordRestock(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
|
||||
$data['serial_no'] = $machine->serial_no;
|
||||
|
||||
\App\Jobs\Machine\ProcessRestockReport::dispatch($data);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Restock report accepted',
|
||||
'status' => '49'
|
||||
], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* B017: Get Slot Info & Stock (Synchronous)
|
||||
*/
|
||||
/**
|
||||
* B017: Get Slot Info & Stock (Synchronous - Full Sync)
|
||||
*/
|
||||
public function getSlots(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$slots = $machine->slots()->with('product')->get();
|
||||
|
||||
// 依貨道編號排序 (Sorted by slot_no as requested)
|
||||
$slots = $machine->slots()->with('product')->orderBy('slot_no')->get();
|
||||
|
||||
// 自動轉 Success: 若機台來撈 B017,代表之前的 reload_stock 指令已成功被機台響應
|
||||
// 同時處理 sent 與 pending 狀態,確保狀態機正確關閉
|
||||
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||||
->where('command_type', 'reload_stock')
|
||||
->whereIn('status', ['pending', 'sent'])
|
||||
->update([
|
||||
'status' => 'success',
|
||||
'executed_at' => now(),
|
||||
'note' => __('Inventory synced with machine')
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => $slots->map(function ($slot) {
|
||||
return [
|
||||
'slot_no' => $slot->slot_no,
|
||||
'tid' => $slot->slot_no,
|
||||
'num' => (int)$slot->stock,
|
||||
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
|
||||
'batch_no' => $slot->batch_no,
|
||||
// 保留原始欄位以供除錯或未來擴充
|
||||
'product_id' => $slot->product_id,
|
||||
'stock' => $slot->stock,
|
||||
'capacity' => $slot->capacity,
|
||||
'price' => $slot->price,
|
||||
'status' => $slot->status,
|
||||
'capacity' => $slot->max_stock,
|
||||
'status' => $slot->is_active ? '1' : '0',
|
||||
];
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* B710: Sync Timer status (Asynchronous)
|
||||
*/
|
||||
public function syncTimer(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
|
||||
|
||||
ProcessTimerStatus::dispatch($machine->serial_no, $data);
|
||||
|
||||
@@ -74,7 +227,7 @@ class MachineController extends Controller
|
||||
public function syncCoinInventory(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
|
||||
|
||||
ProcessCoinInventory::dispatch($machine->serial_no, $data);
|
||||
|
||||
@@ -120,4 +273,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 預期的是包含單一物件的陣列
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ class TransactionController extends Controller
|
||||
public function store(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
|
||||
$data['serial_no'] = $machine->serial_no;
|
||||
|
||||
ProcessTransaction::dispatch($data);
|
||||
@@ -34,7 +34,7 @@ class TransactionController extends Controller
|
||||
public function recordInvoice(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
|
||||
$data['serial_no'] = $machine->serial_no;
|
||||
|
||||
ProcessInvoice::dispatch($data);
|
||||
|
||||
18
app/Http/Controllers/ApiDocsController.php
Normal file
18
app/Http/Controllers/ApiDocsController.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ApiDocsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the API documentation page.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$docs = config('api-docs');
|
||||
|
||||
return view('docs.api-docs', compact('docs'));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,6 @@ class PasswordController extends Controller
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
return back()->with('success', __('Password updated successfully.'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class ProfileController extends Controller
|
||||
|
||||
$user->save();
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
return Redirect::route('profile.edit')->with('success', __('Profile updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +48,7 @@ class ProfileController extends Controller
|
||||
public function updateAvatar(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif', 'max:1024'],
|
||||
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:1024'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
@@ -26,7 +26,7 @@ class EnsureTenantAccess
|
||||
return redirect()->route('login')->with('error', __('Your account is associated with a deactivated company.'));
|
||||
}
|
||||
|
||||
if ($company->valid_until && $company->valid_until->isPast()) {
|
||||
if ($company->end_date && $company->end_date->isPast()) {
|
||||
auth()->logout();
|
||||
return redirect()->route('login')->with('error', __('Your company contract has expired.'));
|
||||
}
|
||||
|
||||
40
app/Jobs/Machine/ProcessMachineError.php
Normal file
40
app/Jobs/Machine/ProcessMachineError.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/Jobs/Machine/ProcessRestockReport.php
Normal file
37
app/Jobs/Machine/ProcessRestockReport.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Machine;
|
||||
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class ProcessRestockReport implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
protected $data;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(\App\Services\Machine\MachineService $machineService): void
|
||||
{
|
||||
$serialNo = $this->data['serial_no'] ?? null;
|
||||
$slotsData = $this->data['slots'] ?? [];
|
||||
|
||||
if (!$serialNo) return;
|
||||
|
||||
$machine = \App\Models\Machine\Machine::where('serial_no', $serialNo)->first();
|
||||
if ($machine) {
|
||||
$machineService->syncSlots($machine, $slotsData);
|
||||
}
|
||||
}
|
||||
}
|
||||
56
app/Jobs/Machine/ProcessStateLog.php
Normal file
56
app/Jobs/Machine/ProcessStateLog.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,32 @@ class Machine extends Model
|
||||
use HasFactory, TenantScoped;
|
||||
use \Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
// 權限隔離:一般帳號登入時只能看到自己被分配的機台
|
||||
static::addGlobalScope('machine_access', function (\Illuminate\Database\Eloquent\Builder $builder) {
|
||||
$user = auth()->user();
|
||||
// 如果是在 Console、或是系統管理員,則不限制 (可看所有機台)
|
||||
if (app()->runningInConsole() || !$user || $user->isSystemAdmin()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 一般租戶帳號:限制只能看自己擁有的機台
|
||||
$builder->whereExists(function ($query) use ($user) {
|
||||
$query->select(\Illuminate\Support\Facades\DB::raw(1))
|
||||
->from('machine_user')
|
||||
->whereColumn('machine_user.machine_id', 'machines.id')
|
||||
->where('machine_user.user_id', $user->id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'name',
|
||||
'serial_no',
|
||||
'model',
|
||||
'location',
|
||||
'status',
|
||||
'current_page',
|
||||
'door_status',
|
||||
'temperature',
|
||||
@@ -49,7 +68,88 @@ class Machine extends Model
|
||||
'updater_id',
|
||||
];
|
||||
|
||||
protected $appends = ['image_urls'];
|
||||
protected $appends = ['image_urls', 'calculated_status'];
|
||||
|
||||
/**
|
||||
* 動態計算機台當前狀態
|
||||
* 1. 離線 (offline):超過 30 秒未收到心跳
|
||||
* 2. 異常 (error):在線但過去 15 分鐘內有錯誤/警告日誌
|
||||
* 3. 在線 (online):正常在線
|
||||
*/
|
||||
public function getCalculatedStatusAttribute(): string
|
||||
{
|
||||
// 判定離線
|
||||
if (!$this->last_heartbeat_at || $this->last_heartbeat_at->diffInSeconds(now()) >= 30) {
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
// 判定異常 (檢查過去 15 分鐘內是否有 error 或 warning 日誌)
|
||||
$hasRecentErrors = $this->logs()
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('created_at', '>=', now()->subMinutes(15))
|
||||
->exists();
|
||||
|
||||
if ($hasRecentErrors) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
return 'online';
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定在線 (30 秒內有心跳)
|
||||
*/
|
||||
public function scopeOnline($query)
|
||||
{
|
||||
return $query->where('last_heartbeat_at', '>=', now()->subSeconds(30));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定離線 (超過 30 秒未收到心跳或從未收到心跳)
|
||||
*/
|
||||
public function scopeOffline($query)
|
||||
{
|
||||
return $query->where(function ($q) {
|
||||
$q->whereNull('last_heartbeat_at')
|
||||
->orWhere('last_heartbeat_at', '<', now()->subSeconds(30));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定異常 (過去 15 分鐘內有錯誤或警告日誌)
|
||||
*/
|
||||
public function scopeHasError($query)
|
||||
{
|
||||
return $query->whereExists(function ($q) {
|
||||
$q->select(\Illuminate\Support\Facades\DB::raw(1))
|
||||
->from('machine_logs')
|
||||
->whereColumn('machine_logs.machine_id', 'machines.id')
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('created_at', '>=', now()->subMinutes(15));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定運行中 (在線且無近期異常)
|
||||
*/
|
||||
public function scopeRunning($query)
|
||||
{
|
||||
return $query->online()->whereNotExists(function ($q) {
|
||||
$q->select(\Illuminate\Support\Facades\DB::raw(1))
|
||||
->from('machine_logs')
|
||||
->whereColumn('machine_logs.machine_id', 'machines.id')
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('created_at', '>=', now()->subMinutes(15));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定異常在線 (在線且有近期異常)
|
||||
*/
|
||||
public function scopeErrorOnline($query)
|
||||
{
|
||||
return $query->online()->hasError();
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
'last_heartbeat_at' => 'datetime',
|
||||
@@ -81,6 +181,16 @@ class Machine extends Model
|
||||
return $this->hasMany(MachineLog::class);
|
||||
}
|
||||
|
||||
public function slots()
|
||||
{
|
||||
return $this->hasMany(MachineSlot::class);
|
||||
}
|
||||
|
||||
public function commands()
|
||||
{
|
||||
return $this->hasMany(RemoteCommand::class);
|
||||
}
|
||||
|
||||
public function machineModel()
|
||||
{
|
||||
return $this->belongsTo(MachineModel::class);
|
||||
@@ -101,4 +211,39 @@ class Machine extends Model
|
||||
return $this->belongsTo(\App\Models\System\User::class, 'updater_id');
|
||||
}
|
||||
|
||||
public const PAGE_STATUSES = [
|
||||
'0' => 'Offline',
|
||||
'1' => 'Home Page',
|
||||
'2' => 'Vending Page',
|
||||
'3' => 'Admin Page',
|
||||
'4' => 'Replenishment Page',
|
||||
'5' => 'Tutorial Page',
|
||||
'6' => 'Purchasing',
|
||||
'7' => 'Locked Page',
|
||||
'60' => 'Dispense Success',
|
||||
'61' => 'Slot Test',
|
||||
'62' => 'Payment Selection',
|
||||
'63' => 'Waiting for Payment',
|
||||
'64' => 'Dispensing',
|
||||
'65' => 'Receipt Printing',
|
||||
'66' => 'Pass Code',
|
||||
'67' => 'Pickup Code',
|
||||
'68' => 'Message Display',
|
||||
'69' => 'Cancel Purchase',
|
||||
'610' => 'Purchase Finished',
|
||||
'611' => 'Welcome Gift',
|
||||
'612' => 'Dispense Failed',
|
||||
];
|
||||
|
||||
public function getCurrentPageLabelAttribute(): string
|
||||
{
|
||||
$code = (string) $this->current_page;
|
||||
$label = self::PAGE_STATUSES[$code] ?? $code;
|
||||
return __($label);
|
||||
}
|
||||
|
||||
public function users()
|
||||
{
|
||||
return $this->belongsToMany(\App\Models\System\User::class);
|
||||
}
|
||||
}
|
||||
|
||||
43
app/Models/Machine/MachineAdvertisement.php
Normal file
43
app/Models/Machine/MachineAdvertisement.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use App\Models\System\Advertisement;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class MachineAdvertisement extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'machine_id',
|
||||
'advertisement_id',
|
||||
'position',
|
||||
'sort_order',
|
||||
'start_at',
|
||||
'end_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
'start_at' => 'datetime',
|
||||
'end_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the advertisement associated with this assignment.
|
||||
*/
|
||||
public function advertisement()
|
||||
{
|
||||
return $this->belongsTo(Advertisement::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the machine associated with this assignment.
|
||||
*/
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,10 @@ class MachineLog extends Model
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
'level',
|
||||
'type',
|
||||
'message',
|
||||
'context',
|
||||
];
|
||||
@@ -22,6 +24,33 @@ class MachineLog extends Model
|
||||
'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()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
|
||||
@@ -14,17 +14,18 @@ class MachineSlot extends Model
|
||||
'machine_id',
|
||||
'product_id',
|
||||
'slot_no',
|
||||
'slot_name',
|
||||
'capacity',
|
||||
'type',
|
||||
'max_stock',
|
||||
'stock',
|
||||
'price',
|
||||
'status',
|
||||
'last_restocked_at',
|
||||
'expiry_date',
|
||||
'batch_no',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'last_restocked_at' => 'datetime',
|
||||
'expiry_date' => 'date:Y-m-d',
|
||||
];
|
||||
|
||||
public function machine()
|
||||
|
||||
47
app/Models/Machine/MaintenanceRecord.php
Normal file
47
app/Models/Machine/MaintenanceRecord.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
use App\Traits\TenantScoped;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use App\Models\System\User;
|
||||
use App\Models\Machine\Machine;
|
||||
|
||||
class MaintenanceRecord extends Model
|
||||
{
|
||||
use TenantScoped, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
'user_id',
|
||||
'category',
|
||||
'content',
|
||||
'photos',
|
||||
'maintenance_at',
|
||||
'is_confirmed',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'photos' => 'array',
|
||||
'maintenance_at' => 'datetime',
|
||||
'is_confirmed' => 'boolean',
|
||||
];
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\Company::class);
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,16 @@ class RemoteCommand extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'machine_id',
|
||||
'command',
|
||||
'user_id',
|
||||
'command_type',
|
||||
'payload',
|
||||
'status',
|
||||
'response_payload',
|
||||
'ttl',
|
||||
'executed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'response_payload' => 'array',
|
||||
'executed_at' => 'datetime',
|
||||
];
|
||||
|
||||
@@ -28,4 +28,17 @@ class RemoteCommand extends Model
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for pending commands
|
||||
*/
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', 'pending')->orderBy('created_at', 'asc');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,26 +10,43 @@ use App\Traits\TenantScoped;
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, TenantScoped;
|
||||
|
||||
/**
|
||||
* Scope a query to only include active products.
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'category_id',
|
||||
'name',
|
||||
'sku',
|
||||
'name_dictionary_key',
|
||||
'barcode',
|
||||
'spec',
|
||||
'manufacturer',
|
||||
'description',
|
||||
'price',
|
||||
'member_price',
|
||||
'cost',
|
||||
'track_limit',
|
||||
'spring_limit',
|
||||
'type',
|
||||
'image_url',
|
||||
'status',
|
||||
'name_dictionary_key',
|
||||
'is_active',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'member_price' => 'decimal:2',
|
||||
'cost' => 'decimal:2',
|
||||
'track_limit' => 'integer',
|
||||
'spring_limit' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
@@ -37,4 +54,40 @@ class Product extends Model
|
||||
{
|
||||
return $this->belongsTo(ProductCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 自動附加到 JSON/陣列輸出的屬性(供 Alpine.js 等前端使用)
|
||||
*/
|
||||
protected $appends = ['localized_name'];
|
||||
|
||||
/**
|
||||
* 取得當前語系的商品名稱。
|
||||
* 回退順序:當前語系 → zh_TW → name 欄位
|
||||
*/
|
||||
public function getLocalizedNameAttribute(): string
|
||||
{
|
||||
if ($this->relationLoaded('translations') && $this->translations->isNotEmpty()) {
|
||||
$locale = app()->getLocale();
|
||||
// 先找當前語系
|
||||
$translation = $this->translations->firstWhere('locale', $locale);
|
||||
if ($translation) {
|
||||
return $translation->value;
|
||||
}
|
||||
// 回退至 zh_TW
|
||||
$fallback = $this->translations->firstWhere('locale', 'zh_TW');
|
||||
if ($fallback) {
|
||||
return $fallback->value;
|
||||
}
|
||||
}
|
||||
return $this->name ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the translations for the product name.
|
||||
*/
|
||||
public function translations()
|
||||
{
|
||||
return $this->hasMany(\App\Models\System\Translation::class, 'key', 'name_dictionary_key')
|
||||
->where('group', 'product');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,44 @@ class ProductCategory extends Model
|
||||
'name_dictionary_key',
|
||||
];
|
||||
|
||||
/**
|
||||
* 自動附加到 JSON/陣列輸出
|
||||
*/
|
||||
protected $appends = ['localized_name'];
|
||||
|
||||
public function products()
|
||||
{
|
||||
return $this->hasMany(Product::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the translations for the category name.
|
||||
*/
|
||||
public function translations()
|
||||
{
|
||||
return $this->hasMany(\App\Models\System\Translation::class, 'key', 'name_dictionary_key')
|
||||
->where('group', 'category');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得當前語系的商品名稱。
|
||||
* 回退順序:當前語系 → zh_TW → name 欄位
|
||||
*/
|
||||
public function getLocalizedNameAttribute(): string
|
||||
{
|
||||
if ($this->relationLoaded('translations') && $this->translations->isNotEmpty()) {
|
||||
$locale = app()->getLocale();
|
||||
// 先找當前語系
|
||||
$translation = $this->translations->firstWhere('locale', $locale);
|
||||
if ($translation) {
|
||||
return $translation->value;
|
||||
}
|
||||
// 回退至 zh_TW
|
||||
$fallback = $this->translations->firstWhere('locale', 'zh_TW');
|
||||
if ($fallback) {
|
||||
return $fallback->value;
|
||||
}
|
||||
}
|
||||
return $this->name ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
72
app/Models/System/Advertisement.php
Normal file
72
app/Models/System/Advertisement.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
use App\Models\Machine\MachineAdvertisement;
|
||||
use App\Traits\TenantScoped;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Advertisement extends Model
|
||||
{
|
||||
use HasFactory, TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'name',
|
||||
'type',
|
||||
'duration',
|
||||
'url',
|
||||
'is_active',
|
||||
'start_at',
|
||||
'end_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'duration' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
'start_at' => 'datetime',
|
||||
'end_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the machine assignments for this advertisement.
|
||||
*/
|
||||
public function machineAdvertisements()
|
||||
{
|
||||
return $this->hasMany(MachineAdvertisement::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the company that owns the advertisement.
|
||||
*/
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include active advertisements.
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,20 +16,42 @@ class Company extends Model
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'code',
|
||||
'original_type',
|
||||
'current_type',
|
||||
'tax_id',
|
||||
'contact_name',
|
||||
'contact_phone',
|
||||
'contact_email',
|
||||
'status',
|
||||
'valid_until',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'warranty_start_date',
|
||||
'warranty_end_date',
|
||||
'software_start_date',
|
||||
'software_end_date',
|
||||
'note',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'valid_until' => 'date',
|
||||
'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',
|
||||
'status' => 'integer',
|
||||
'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.
|
||||
*/
|
||||
|
||||
50
app/Models/System/CompanyContract.php
Normal file
50
app/Models/System/CompanyContract.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@ class Role extends SpatieRole
|
||||
'is_system',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_system' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the company that owns the role.
|
||||
*/
|
||||
|
||||
@@ -7,9 +7,10 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Translation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory, \App\Traits\TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'group',
|
||||
'key',
|
||||
'locale',
|
||||
|
||||
@@ -31,6 +31,7 @@ class User extends Authenticatable
|
||||
'avatar',
|
||||
'role',
|
||||
'status',
|
||||
'is_admin',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -51,6 +52,7 @@ class User extends Authenticatable
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_admin' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -69,6 +71,14 @@ class User extends Authenticatable
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the machines assigned to the user.
|
||||
*/
|
||||
public function machines()
|
||||
{
|
||||
return $this->belongsToMany(\App\Models\Machine\Machine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is a system administrator.
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,7 @@ class OrderItem extends Model
|
||||
'order_id',
|
||||
'product_id',
|
||||
'product_name',
|
||||
'sku',
|
||||
'barcode',
|
||||
'price',
|
||||
'quantity',
|
||||
'subtotal',
|
||||
|
||||
66
app/Policies/Machine/MaintenanceRecordPolicy.php
Normal file
66
app/Policies/Machine/MaintenanceRecordPolicy.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies\Machine;
|
||||
|
||||
use App\Models\Machine\MaintenanceRecord;
|
||||
use App\Models\System\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class MaintenanceRecordPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return $user->isSystemAdmin() && $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
* @var array<class-string, class-string>
|
||||
*/
|
||||
protected $policies = [
|
||||
//
|
||||
\App\Models\Machine\MaintenanceRecord::class => \App\Policies\Machine\MaintenanceRecordPolicy::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,11 +4,62 @@ namespace App\Services\Machine;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineLog;
|
||||
use App\Models\Machine\RemoteCommand;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -21,12 +72,19 @@ class MachineService
|
||||
return DB::transaction(function () use ($serialNo, $data) {
|
||||
$machine = Machine::where('serial_no', $serialNo)->firstOrFail();
|
||||
|
||||
// 採用現代化語意命名 (Modern semantic naming)
|
||||
$temperature = $data['temperature'] ?? $machine->temperature;
|
||||
$currentPage = $data['current_page'] ?? $machine->current_page;
|
||||
$doorStatus = $data['door_status'] ?? $machine->door_status;
|
||||
$firmwareVersion = $data['firmware_version'] ?? $machine->firmware_version;
|
||||
$model = $data['model'] ?? $machine->model;
|
||||
|
||||
$updateData = [
|
||||
'status' => 'online',
|
||||
'temperature' => $data['temperature'] ?? $machine->temperature,
|
||||
'current_page' => $data['current_page'] ?? $machine->current_page,
|
||||
'door_status' => $data['door_status'] ?? $machine->door_status,
|
||||
'firmware_version' => $data['firmware_version'] ?? $machine->firmware_version,
|
||||
'temperature' => $temperature,
|
||||
'current_page' => $currentPage,
|
||||
'door_status' => $doorStatus,
|
||||
'firmware_version' => $firmwareVersion,
|
||||
'model' => $model,
|
||||
'last_heartbeat_at' => now(),
|
||||
];
|
||||
|
||||
@@ -35,9 +93,11 @@ class MachineService
|
||||
// Record log if provided
|
||||
if (!empty($data['log'])) {
|
||||
$machine->logs()->create([
|
||||
'company_id' => $machine->company_id,
|
||||
'type' => 'status',
|
||||
'level' => $data['log_level'] ?? 'info',
|
||||
'message' => $data['log'],
|
||||
'payload' => $data['log_payload'] ?? null,
|
||||
'context' => $data['log_payload'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -46,17 +106,160 @@ class MachineService
|
||||
}
|
||||
|
||||
/**
|
||||
* Update machine slot stock.
|
||||
* Sync machine slots based on replenishment report.
|
||||
*
|
||||
* @param Machine $machine
|
||||
* @param array $slotsData
|
||||
*/
|
||||
public function updateSlotStock(Machine $machine, int $slotNo, int $stock): void
|
||||
public function syncSlots(Machine $machine, array $slotsData): void
|
||||
{
|
||||
$machine->slots()->where('slot_no', $slotNo)->update([
|
||||
'stock' => $stock,
|
||||
'last_restocked_at' => now(),
|
||||
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) {
|
||||
$slotNo = $slotData['slot_no'] ?? null;
|
||||
if (!$slotNo) continue;
|
||||
|
||||
$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 = [
|
||||
'product_id' => $actualProductId,
|
||||
'type' => $slotType,
|
||||
'stock' => $slotData['stock'] ?? 0,
|
||||
'max_stock' => $slotData['capacity'] ?? ($existingSlot->max_stock ?? 10),
|
||||
'is_active' => true,
|
||||
];
|
||||
|
||||
// 如果這是一次明確的補貨回報,建議更新時間並記錄
|
||||
if ($existingSlot) {
|
||||
$existingSlot->update($updateData);
|
||||
} else {
|
||||
$machine->slots()->create(array_merge($updateData, ['slot_no' => $slotNo]));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update machine slot stock, expiry, and batch.
|
||||
*
|
||||
* @param Machine $machine
|
||||
* @param array $data
|
||||
* @param int|null $userId
|
||||
* @return void
|
||||
*/
|
||||
public function updateSlot(Machine $machine, array $data, ?int $userId = null): void
|
||||
{
|
||||
DB::transaction(function () use ($machine, $data, $userId) {
|
||||
$slotNo = $data['slot_no'];
|
||||
$stock = $data['stock'] ?? null;
|
||||
$expiryDate = $data['expiry_date'] ?? null;
|
||||
$batchNo = $data['batch_no'] ?? null;
|
||||
$slot = $machine->slots()->where('slot_no', $slotNo)->firstOrFail();
|
||||
|
||||
// 紀錄舊數據以供 Payload 使用
|
||||
$oldData = [
|
||||
'stock' => $slot->stock,
|
||||
'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null,
|
||||
'batch_no' => $slot->batch_no,
|
||||
];
|
||||
|
||||
$updateData = [
|
||||
'expiry_date' => $expiryDate,
|
||||
'batch_no' => $batchNo,
|
||||
];
|
||||
if ($stock !== null) $updateData['stock'] = (int)$stock;
|
||||
|
||||
$slot->update($updateData);
|
||||
|
||||
// 指令去重:將該機台所有尚未領取的舊庫存同步指令標記為「已取代」
|
||||
RemoteCommand::where('machine_id', $machine->id)
|
||||
->where('command_type', 'reload_stock')
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'superseded',
|
||||
'note' => __('Superseded by new adjustment'),
|
||||
'executed_at' => now(),
|
||||
]);
|
||||
|
||||
// 建立遠端指令紀錄 (Unified Command Concept)
|
||||
RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
'user_id' => $userId,
|
||||
'command_type' => 'reload_stock',
|
||||
'status' => 'pending',
|
||||
'payload' => [
|
||||
'slot_no' => $slotNo,
|
||||
'old' => $oldData,
|
||||
'new' => [
|
||||
'stock' => $stock !== null ? (int)$stock : $oldData['stock'],
|
||||
'expiry_date' => $expiryDate ?: null,
|
||||
'batch_no' => $batchNo ?: null,
|
||||
]
|
||||
]
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Legacy support for recordLog (Existing code).
|
||||
*/
|
||||
public function recordLog(int $machineId, array $data): MachineLog
|
||||
@@ -66,7 +269,174 @@ class MachineService
|
||||
return $machine->logs()->create([
|
||||
'level' => $data['level'] ?? 'info',
|
||||
'message' => $data['message'],
|
||||
'payload' => $data['context'] ?? null,
|
||||
'context' => $data['context'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get machine utilization and OEE statistics for entire fleet.
|
||||
*/
|
||||
public function getFleetStats(string $date): array
|
||||
{
|
||||
$start = Carbon::parse($date)->startOfDay();
|
||||
$end = Carbon::parse($date)->endOfDay();
|
||||
|
||||
// 1. Online Count (Base on new heartbeat logic)
|
||||
$machines = Machine::all(); // This is filtered by TenantScoped
|
||||
$totalMachines = $machines->count();
|
||||
$onlineCount = Machine::online()->count();
|
||||
|
||||
$machineIds = $machines->pluck('id')->toArray();
|
||||
|
||||
// 2. Total Daily Sales (Sum of B600 logs across all authorized machines)
|
||||
$totalSales = MachineLog::whereIn('machine_id', $machineIds)
|
||||
->where('message', 'like', '%B600%')
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->count();
|
||||
|
||||
// 3. Average OEE (Simulated based on individual machine stats for performance)
|
||||
$totalOee = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($machines as $machine) {
|
||||
$stats = $this->getUtilizationStats($machine, $date);
|
||||
$totalOee += $stats['overview']['oee'];
|
||||
$count++;
|
||||
}
|
||||
|
||||
$avgOee = ($count > 0) ? ($totalOee / $count) : 0;
|
||||
|
||||
return [
|
||||
'avgOee' => round($avgOee, 2),
|
||||
'onlineCount' => $onlineCount,
|
||||
'totalMachines' => $totalMachines,
|
||||
'totalSales' => $totalSales,
|
||||
'alertCount' => MachineLog::whereIn('machine_id', $machineIds)
|
||||
->where('level', 'error')
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->count()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get machine utilization and OEE statistics.
|
||||
*/
|
||||
public function getUtilizationStats(Machine $machine, string $date): array
|
||||
{
|
||||
$start = Carbon::parse($date)->startOfDay();
|
||||
$end = Carbon::parse($date)->endOfDay();
|
||||
|
||||
// 1. Availability: Based on heartbeat logs (status type)
|
||||
// Assume online if heartbeat within 6 minutes
|
||||
$logs = $machine->logs()
|
||||
->where('type', 'status')
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
$onlineMinutes = 0;
|
||||
$lastLogTime = null;
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$currentTime = Carbon::parse($log->created_at);
|
||||
if ($lastLogTime) {
|
||||
$diff = $currentTime->diffInMinutes($lastLogTime);
|
||||
if ($diff <= 6) {
|
||||
$onlineMinutes += $diff;
|
||||
}
|
||||
}
|
||||
$lastLogTime = $currentTime;
|
||||
}
|
||||
|
||||
$totalMinutes = 24 * 60;
|
||||
$availability = ($totalMinutes > 0) ? min(100, ($onlineMinutes / $totalMinutes) * 100) : 0;
|
||||
|
||||
// 2. Performance: Sales Count (B600)
|
||||
// Target: 2 sales per hour (48/day)
|
||||
$salesCount = $machine->logs()
|
||||
->where('message', 'like', '%B600%')
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->count();
|
||||
|
||||
$targetSales = 48;
|
||||
$performance = ($targetSales > 0) ? min(100, ($salesCount / $targetSales) * 100) : 0;
|
||||
|
||||
// 3. Quality: Success Rate
|
||||
// Exclude failed dispense (B130)
|
||||
$errorCount = $machine->logs()
|
||||
->where('message', 'like', '%B130%')
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->count();
|
||||
|
||||
$totalAttempts = $salesCount + $errorCount;
|
||||
$quality = ($totalAttempts > 0) ? (($salesCount / $totalAttempts) * 100) : 100;
|
||||
|
||||
// Combined OEE
|
||||
$oee = ($availability / 100) * ($performance / 100) * ($quality / 100) * 100;
|
||||
|
||||
return [
|
||||
'overview' => [
|
||||
'availability' => round($availability, 2),
|
||||
'performance' => round($performance, 2),
|
||||
'quality' => round($quality, 2),
|
||||
'oee' => round($oee, 2),
|
||||
'onlineHours' => round($onlineMinutes / 60, 2),
|
||||
'salesCount' => $salesCount,
|
||||
'errorCount' => $errorCount,
|
||||
],
|
||||
'chart' => [
|
||||
'uptime' => $this->formatUptimeTimeline($logs, $start, $end),
|
||||
'sales' => $this->formatSalesTimeline($machine, $start, $end)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
private function formatUptimeTimeline($logs, $start, $end)
|
||||
{
|
||||
$data = [];
|
||||
if ($logs->isEmpty()) return $data;
|
||||
|
||||
$lastLog = null;
|
||||
$currentRangeStart = null;
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$logTime = Carbon::parse($log->created_at);
|
||||
if (!$currentRangeStart) {
|
||||
$currentRangeStart = $logTime;
|
||||
} else {
|
||||
$diff = $logTime->diffInMinutes(Carbon::parse($lastLog->created_at));
|
||||
if ($diff > 10) { // Interruption > 10 mins
|
||||
$data[] = [
|
||||
'x' => 'Uptime',
|
||||
'y' => [$currentRangeStart->getTimestamp() * 1000, Carbon::parse($lastLog->created_at)->getTimestamp() * 1000],
|
||||
'fillColor' => '#06b6d4'
|
||||
];
|
||||
$currentRangeStart = $logTime;
|
||||
}
|
||||
}
|
||||
$lastLog = $log;
|
||||
}
|
||||
|
||||
if ($currentRangeStart && $lastLog) {
|
||||
$data[] = [
|
||||
'x' => 'Uptime',
|
||||
'y' => [$currentRangeStart->getTimestamp() * 1000, Carbon::parse($lastLog->created_at)->getTimestamp() * 1000],
|
||||
'fillColor' => '#06b6d4'
|
||||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function formatSalesTimeline($machine, $start, $end)
|
||||
{
|
||||
return $machine->logs()
|
||||
->where('message', 'like', '%B600%')
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->get()
|
||||
->map(function($log) {
|
||||
return [Carbon::parse($log->created_at)->getTimestamp() * 1000, 1];
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ class TransactionService
|
||||
$order->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'product_name' => $item['product_name'] ?? 'Unknown',
|
||||
'sku' => $item['sku'] ?? null,
|
||||
'barcode' => $item['barcode'] ?? null,
|
||||
'price' => $item['price'],
|
||||
'quantity' => $item['quantity'],
|
||||
'subtotal' => $item['price'] * $item['quantity'],
|
||||
|
||||
70
app/Traits/ImageHandler.php
Normal file
70
app/Traits/ImageHandler.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait ImageHandler
|
||||
{
|
||||
/**
|
||||
* 將圖片轉換為 WebP 並儲存
|
||||
*
|
||||
* @param UploadedFile $file 原始檔案
|
||||
* @param string $directory 儲存目錄 (不含 disk 名稱)
|
||||
* @param int $quality 壓縮品質 (0-100)
|
||||
* @return string 儲存的路徑
|
||||
*/
|
||||
protected function storeAsWebp(UploadedFile $file, string $directory, int $quality = 80): string
|
||||
{
|
||||
$filename = Str::random(40) . '.webp';
|
||||
$path = "{$directory}/{$filename}";
|
||||
|
||||
// 讀取原始圖片資訊
|
||||
$imageInfo = getimagesize($file->getRealPath());
|
||||
if (!$imageInfo) {
|
||||
return $file->store($directory, 'public');
|
||||
}
|
||||
|
||||
$mime = $imageInfo['mime'];
|
||||
$source = null;
|
||||
|
||||
switch ($mime) {
|
||||
case 'image/jpeg':
|
||||
$source = imagecreatefromjpeg($file->getRealPath());
|
||||
break;
|
||||
case 'image/png':
|
||||
$source = imagecreatefrompng($file->getRealPath());
|
||||
break;
|
||||
case 'image/gif':
|
||||
$source = imagecreatefromgif($file->getRealPath());
|
||||
break;
|
||||
case 'image/webp':
|
||||
$source = imagecreatefromwebp($file->getRealPath());
|
||||
break;
|
||||
default:
|
||||
// 不支援的格式直接存
|
||||
return $file->store($directory, 'public');
|
||||
}
|
||||
|
||||
if (!$source) {
|
||||
return $file->store($directory, 'public');
|
||||
}
|
||||
|
||||
// 確保支援真彩色 (解決 palette image 問題)
|
||||
if (!imageistruecolor($source)) {
|
||||
imagepalettetotruecolor($source);
|
||||
}
|
||||
|
||||
// 確保目錄存在
|
||||
Storage::disk('public')->makeDirectory($directory);
|
||||
$fullPath = Storage::disk('public')->path($path);
|
||||
|
||||
// 轉換並儲存
|
||||
imagewebp($source, $fullPath, $quality);
|
||||
imagedestroy($source);
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
18
compose.yaml
18
compose.yaml
@@ -28,6 +28,24 @@ services:
|
||||
- mysql
|
||||
- redis
|
||||
|
||||
laravel.queue:
|
||||
image: 'sail-8.5/app'
|
||||
container_name: star-cloud-queue
|
||||
hostname: star-cloud-queue
|
||||
command: php artisan queue:work --tries=3 --timeout=90
|
||||
environment:
|
||||
WWWUSER: '${WWWUSER}'
|
||||
LARAVEL_SAIL: 1
|
||||
TZ: 'Asia/Taipei'
|
||||
volumes:
|
||||
- '.:/var/www/html'
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
- mysql
|
||||
- redis
|
||||
restart: always
|
||||
|
||||
mysql:
|
||||
image: 'mysql/mysql-server:8.0'
|
||||
container_name: star-cloud-mysql
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"simplesoftwareio/simple-qrcode": "^4.2",
|
||||
"spatie/laravel-permission": "^7.2"
|
||||
},
|
||||
"require-dev": {
|
||||
|
||||
174
composer.lock
generated
174
composer.lock
generated
@@ -4,8 +4,62 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "a723334f883b537b67e4475890eb949e",
|
||||
"content-hash": "2889e194212440faeb9f8f3dd7513795",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
"version": "2.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Bacon/BaconQrCode.git",
|
||||
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22",
|
||||
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dasprid/enum": "^1.0.3",
|
||||
"ext-iconv": "*",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phly/keep-a-changelog": "^2.1",
|
||||
"phpunit/phpunit": "^7 | ^8 | ^9",
|
||||
"spatie/phpunit-snapshot-assertions": "^4.2.9",
|
||||
"squizlabs/php_codesniffer": "^3.4"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-imagick": "to generate QR code images"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BaconQrCode\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "BaconQrCode is a QR code generator for PHP.",
|
||||
"homepage": "https://github.com/Bacon/BaconQrCode",
|
||||
"support": {
|
||||
"issues": "https://github.com/Bacon/BaconQrCode/issues",
|
||||
"source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8"
|
||||
},
|
||||
"time": "2022-12-07T17:46:57+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.14.8",
|
||||
@@ -135,6 +189,56 @@
|
||||
],
|
||||
"time": "2024-02-09T16:56:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dasprid/enum",
|
||||
"version": "1.0.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DASPRiD/Enum.git",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1 <9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"DASPRiD\\Enum\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP 7.1 enum implementation",
|
||||
"keywords": [
|
||||
"enum",
|
||||
"map"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/DASPRiD/Enum/issues",
|
||||
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
|
||||
},
|
||||
"time": "2025-09-16T12:23:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dflydev/dot-access-data",
|
||||
"version": "v3.0.3",
|
||||
@@ -3554,6 +3658,74 @@
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "simplesoftwareio/simple-qrcode",
|
||||
"version": "4.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/SimpleSoftwareIO/simple-qrcode.git",
|
||||
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/SimpleSoftwareIO/simple-qrcode/zipball/916db7948ca6772d54bb617259c768c9cdc8d537",
|
||||
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"bacon/bacon-qr-code": "^2.0",
|
||||
"ext-gd": "*",
|
||||
"php": ">=7.2|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "~1",
|
||||
"phpunit/phpunit": "~9"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-imagick": "Allows the generation of PNG QrCodes.",
|
||||
"illuminate/support": "Allows for use within Laravel."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"QrCode": "SimpleSoftwareIO\\QrCode\\Facades\\QrCode"
|
||||
},
|
||||
"providers": [
|
||||
"SimpleSoftwareIO\\QrCode\\QrCodeServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"SimpleSoftwareIO\\QrCode\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Simple Software LLC",
|
||||
"email": "support@simplesoftware.io"
|
||||
}
|
||||
],
|
||||
"description": "Simple QrCode is a QR code generator made for Laravel.",
|
||||
"homepage": "https://www.simplesoftware.io/#/docs/simple-qrcode",
|
||||
"keywords": [
|
||||
"Simple",
|
||||
"generator",
|
||||
"laravel",
|
||||
"qrcode",
|
||||
"wrapper"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/SimpleSoftwareIO/simple-qrcode/issues",
|
||||
"source": "https://github.com/SimpleSoftwareIO/simple-qrcode/tree/4.2.0"
|
||||
},
|
||||
"time": "2021-02-08T20:43:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-package-tools",
|
||||
"version": "1.93.0",
|
||||
|
||||
599
config/api-docs.php
Normal file
599
config/api-docs.php
Normal file
@@ -0,0 +1,599 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Star Cloud IoT API 說明文件',
|
||||
'version' => 'v1.0.0',
|
||||
'description' => '此文件提供 Star Cloud 智能販賣機 IoT 端點通訊協議說明,供硬體端與前端開發者調研與串接使用。',
|
||||
'categories' => [
|
||||
[
|
||||
'name' => '機台核心通訊 (IoT Core)',
|
||||
'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' => 'GET',
|
||||
'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' => [],
|
||||
'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)',
|
||||
'slug' => 'b010-heartbeat',
|
||||
'method' => 'POST',
|
||||
'path' => '/api/v1/app/machine/status/B010',
|
||||
'description' => '機台定期向雲端回報當前頁面、版本、溫度及門禁狀態。身份由 Bearer Token 識別。',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer <api_token>',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'parameters' => [
|
||||
'current_page' => [
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'description' => '當前頁面編號。對照表:
|
||||
0: 離線, 1: 主頁面, 2: 販賣頁, 3: 管理頁, 4: 補貨頁, 5: 教學頁
|
||||
6: 購買中, 7: 鎖定頁, 60: 出貨成功, 61: 貨道測試, 62: 付款選擇
|
||||
63: 等待付款, 64: 出貨, 65: 收據簽單, 66: 通行碼, 67: 取貨碼
|
||||
68: 訊息顯示, 69: 取消購買, 610: 購買結束, 611: 來店禮, 612: 出貨失敗',
|
||||
'example' => 1
|
||||
],
|
||||
'firmware_version' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'description' => '軟體或韌體版本號',
|
||||
'example' => '1.0.5'
|
||||
],
|
||||
'model' => [
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
'description' => '機台型號',
|
||||
'example' => 'STAR-V1'
|
||||
],
|
||||
'temperature' => [
|
||||
'type' => 'float',
|
||||
'required' => false,
|
||||
'description' => '感測環境溫度',
|
||||
'example' => 25.5
|
||||
],
|
||||
'door_status' => [
|
||||
'type' => 'integer',
|
||||
'required' => false,
|
||||
'description' => '門禁狀態 (0: 關閉, 1: 開啟)',
|
||||
'example' => 0
|
||||
],
|
||||
'log' => [
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
'description' => '事件日誌主訊息',
|
||||
'example' => 'Door opened'
|
||||
],
|
||||
'log_level' => [
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
'description' => '日誌等級 (info, warning, error)',
|
||||
'example' => 'info'
|
||||
],
|
||||
'log_payload' => [
|
||||
'type' => 'object',
|
||||
'required' => false,
|
||||
'description' => '詳細上下文 (JSON 對象)',
|
||||
'example' => ['error_code' => 500, 'component' => 'door_sensor']
|
||||
],
|
||||
],
|
||||
'response_parameters' => [
|
||||
'success' => [
|
||||
'type' => 'boolean',
|
||||
'description' => '請求是否處理成功',
|
||||
'example' => true
|
||||
],
|
||||
'code' => [
|
||||
'type' => 'integer',
|
||||
'description' => '內部業務狀態碼',
|
||||
'example' => 200
|
||||
],
|
||||
'message' => [
|
||||
'type' => 'string',
|
||||
'description' => '回應訊息說明',
|
||||
'example' => 'OK'
|
||||
],
|
||||
'status' => [
|
||||
'type' => 'string',
|
||||
'description' => '雲端指令代碼。對照表:
|
||||
49: reload B017 (貨道同步), 51: reboot (重啟系統)
|
||||
60: reboot card machine (刷卡機重啟), 61: checkout (觸發結帳)
|
||||
70: unlock (解鎖), 71: lock (鎖定), 85: reload B0552 (遠端出貨)
|
||||
待定義: change (遠端找零 - 目前 Java App 尚無對接)',
|
||||
'example' => '49'
|
||||
],
|
||||
],
|
||||
'request' => [
|
||||
'current_page' => 1,
|
||||
'firmware_version' => '1.0.5',
|
||||
'model' => 'STAR-V1',
|
||||
'temperature' => 25.5,
|
||||
'door_status' => 0,
|
||||
'log' => 'Door opened',
|
||||
'log_level' => 'info',
|
||||
'log_payload' => [
|
||||
'error_code' => 500,
|
||||
'component' => 'door_sensor'
|
||||
],
|
||||
],
|
||||
'response' => [
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'OK',
|
||||
'status' => '49'
|
||||
],
|
||||
'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: 取貨門異常...等。'
|
||||
],
|
||||
[
|
||||
'name' => 'B017: 貨道庫存同步 (Slot Synchronization)',
|
||||
'slug' => 'b017-slot-sync',
|
||||
'method' => 'GET',
|
||||
'path' => '/api/v1/app/machine/reload_msg/B017',
|
||||
'description' => '用於機台端獲獲取所有貨道的最新庫存、效期與狀態。通常由 B010 回傳 status: 49 時觸發。',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer <api_token>',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'parameters' => [],
|
||||
'response_parameters' => [
|
||||
'success' => [
|
||||
'type' => 'boolean',
|
||||
'description' => '是否成功',
|
||||
'example' => true
|
||||
],
|
||||
'data' => [
|
||||
'type' => 'array',
|
||||
'description' => '貨道數據陣列。',
|
||||
'example' => [
|
||||
[
|
||||
'tid' => '1',
|
||||
'num' => 10,
|
||||
'expiry_date' => '2026-12-31',
|
||||
'batch_no' => 'B2026',
|
||||
'status' => '1'
|
||||
]
|
||||
]
|
||||
],
|
||||
],
|
||||
'request' => [],
|
||||
'response' => [
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => [
|
||||
[
|
||||
'tid' => '1',
|
||||
'num' => 10,
|
||||
'expiry_date' => '2026-12-31',
|
||||
'batch_no' => 'B2026',
|
||||
'product_id' => 1,
|
||||
'capacity' => 15,
|
||||
'status' => '1'
|
||||
]
|
||||
]
|
||||
],
|
||||
'notes' => 'B017 為全量同步。實作上後端會依據 slot_no 進行排序,並將相關指令狀態更新為已完成。'
|
||||
],
|
||||
[
|
||||
'name' => 'B024: 取貨碼/通行碼驗證與消耗回報',
|
||||
'slug' => 'b024-access-code',
|
||||
'method' => 'POST/PUT',
|
||||
'path' => '/api/v1/app/sell/access-code/B024',
|
||||
'description' => '處理代碼取貨流程。POST 用於驗證碼有效性,PUT 用於回報出貨成功並消耗代碼。',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer <api_token>',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'parameters' => [
|
||||
'passCode' => [
|
||||
'type' => 'string',
|
||||
'description' => '取貨碼 (POST)',
|
||||
],
|
||||
'accessCodeId' => [
|
||||
'type' => 'string',
|
||||
'description' => '代碼 ID (PUT)',
|
||||
],
|
||||
'status' => [
|
||||
'type' => 'string',
|
||||
'description' => '出貨狀態 (PUT: 1:成功, 0:失敗)',
|
||||
],
|
||||
],
|
||||
'response_parameters' => [
|
||||
'res1' => ['type' => 'string', 'description' => '雲端關聯 ID'],
|
||||
'res3' => ['type' => 'string', 'description' => '預計出貨商品 ID'],
|
||||
],
|
||||
'request' => [
|
||||
'passCode' => '12345678'
|
||||
],
|
||||
'response' => [
|
||||
'success' => true,
|
||||
'res1' => '99',
|
||||
'res3' => '5'
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'B027: 贈品碼/優惠券驗證與消耗回報',
|
||||
'slug' => 'b027-freebie-code',
|
||||
'method' => 'POST/PUT',
|
||||
'path' => '/api/v1/app/sell/free-gift/B027',
|
||||
'description' => '處理贈品券與 0 元購活動。邏輯與 B024 相似但對象為行銷贈品。',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer <api_token>',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'parameters' => [
|
||||
'passCode' => [
|
||||
'type' => 'string',
|
||||
'description' => '贈品碼 (POST)',
|
||||
],
|
||||
],
|
||||
'response_parameters' => [
|
||||
'success' => ['type' => 'boolean', 'description' => '驗證結果'],
|
||||
],
|
||||
'request' => [
|
||||
'passCode' => 'FREE888'
|
||||
],
|
||||
'response' => [
|
||||
'success' => true,
|
||||
'message' => 'Free gift verified'
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'B055: 遠端指令出貨控制 (Remote Dispense)',
|
||||
'slug' => 'b055-remote-dispense',
|
||||
'method' => 'POST/PUT',
|
||||
'path' => '/api/v1/app/machine/dispense/B055',
|
||||
'description' => '遠端手動驅動機台出貨。POST 用於獲取待處理指令,PUT 用於回報出貨完成。',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer <api_token>',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'parameters' => [
|
||||
'id' => ['type' => 'string', 'description' => '指令 ID (PUT)'],
|
||||
'stock' => ['type' => 'string', 'description' => '剩餘庫存 (PUT)'],
|
||||
],
|
||||
'request' => [],
|
||||
'response' => [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
['slot_no' => '1', 'order_id' => 'RE-123']
|
||||
]
|
||||
],
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
@@ -14,9 +14,9 @@ class MachineFactory extends Factory
|
||||
return [
|
||||
'name' => 'Machine-' . fake()->unique()->numberBetween(101, 999),
|
||||
'location' => fake()->address(),
|
||||
'status' => fake()->randomElement(['online', 'offline', 'error']),
|
||||
'temperature' => fake()->randomFloat(2, 2, 10),
|
||||
'firmware_version' => 'v' . fake()->randomElement(['1.0.0', '1.1.2', '2.0.1']),
|
||||
'serial_no' => 'SN-' . strtoupper(fake()->unique()->bothify('??###?')),
|
||||
'last_heartbeat_at' => fake()->dateTimeBetween('-1 day', 'now'),
|
||||
];
|
||||
}
|
||||
|
||||
24
database/factories/System/CompanyFactory.php
Normal file
24
database/factories/System/CompanyFactory.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories\System;
|
||||
|
||||
use App\Models\System\Company;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class CompanyFactory extends Factory
|
||||
{
|
||||
protected $model = Company::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->company,
|
||||
'code' => $this->faker->unique()->bothify('COMP###'),
|
||||
'status' => 1,
|
||||
'settings' => [
|
||||
'enable_material_code' => false,
|
||||
'enable_points' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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('machine_user', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('machine_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['machine_id', 'user_id']); // Ensure uniqueness
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('machine_user');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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_logs', function (Blueprint $table) {
|
||||
$table->foreignId('company_id')->after('id')->nullable()->constrained()->onDelete('cascade');
|
||||
$table->string('type')->after('level')->default('status')->index(); // status, login, submachine, device
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('machine_logs', function (Blueprint $table) {
|
||||
$table->dropForeign(['company_id']);
|
||||
$table->dropColumn(['company_id', 'type']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('machine_slots', function (Blueprint $table) {
|
||||
$table->date('expiry_date')->nullable()->after('max_stock')->comment('商品效期');
|
||||
$table->string('batch_no')->nullable()->after('expiry_date')->comment('補貨批號');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('machine_slots', function (Blueprint $table) {
|
||||
$table->dropColumn(['expiry_date', 'batch_no']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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('maintenance_records', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('company_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('machine_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->string('category')->comment('維修、裝機、撤機、保養');
|
||||
$table->text('content')->nullable();
|
||||
$table->json('photos')->nullable();
|
||||
$table->timestamp('maintenance_at')->useCurrent();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('maintenance_records');
|
||||
}
|
||||
};
|
||||
@@ -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('maintenance_records', function (Blueprint $table) {
|
||||
$table->boolean('is_confirmed')->default(false)->after('photos')->comment('已確認告知客戶並簽名');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('maintenance_records', function (Blueprint $table) {
|
||||
$table->dropColumn('is_confirmed');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<?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
|
||||
{
|
||||
// 擴充 products 表
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->string('spec')->nullable()->after('name')->comment('規格');
|
||||
$table->string('manufacturer')->nullable()->after('barcode')->comment('生產公司');
|
||||
$table->integer('track_limit')->default(0)->after('manufacturer')->comment('履帶貨道上限');
|
||||
$table->integer('spring_limit')->default(0)->after('track_limit')->comment('彈簧貨道上限');
|
||||
$table->decimal('member_price', 10, 2)->default(0)->after('price')->comment('會員價');
|
||||
$table->json('metadata')->nullable()->after('is_active')->comment('進階 Metadata (點數、物料代碼等)');
|
||||
});
|
||||
|
||||
// 擴充 companies 表
|
||||
Schema::table('companies', function (Blueprint $table) {
|
||||
$table->json('settings')->nullable()->after('note')->comment('客戶功能設定 (Feature Toggles)');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'spec',
|
||||
'manufacturer',
|
||||
'track_limit',
|
||||
'spring_limit',
|
||||
'member_price',
|
||||
'metadata'
|
||||
]);
|
||||
});
|
||||
|
||||
Schema::table('companies', function (Blueprint $table) {
|
||||
$table->dropColumn('settings');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('companies', function (Blueprint $table) {
|
||||
$table->string('code', 100)->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('companies', function (Blueprint $table) {
|
||||
$table->string('code', 20)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('translations', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('company_id')->nullable()->after('id')->index()->comment('所屬公司 ID');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('translations', function (Blueprint $table) {
|
||||
$table->dropColumn('company_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('products', function (Blueprint $table) {
|
||||
$table->renameColumn('image', 'image_url');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->renameColumn('image_url', 'image');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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->renameColumn('valid_until', 'end_date');
|
||||
|
||||
// 新增業務類型
|
||||
$table->string('original_type')->default('lease')->after('code')->comment('原始類型: buyout, lease');
|
||||
$table->string('current_type')->default('lease')->after('original_type')->comment('當前類型: buyout, lease');
|
||||
|
||||
// 新增起始日
|
||||
$table->date('start_date')->nullable()->after('status')->comment('合約起始日');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('companies', function (Blueprint $table) {
|
||||
$table->renameColumn('end_date', 'valid_until');
|
||||
$table->dropColumn(['original_type', 'current_type', 'start_date']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->boolean('is_admin')->default(false)->after('is_system');
|
||||
});
|
||||
|
||||
// 資料遷移:將所有租戶中名稱為「管理員」的角色標示為 is_admin = true
|
||||
// 這樣既有的授權篩選才不會斷掉
|
||||
DB::table('roles')
|
||||
->whereNotNull('company_id')
|
||||
->where('name', '管理員')
|
||||
->update(['is_admin' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->dropColumn('is_admin');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. 從 roles 移除 is_admin
|
||||
if (Schema::hasColumn('roles', 'is_admin')) {
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->dropColumn('is_admin');
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 在 users 新增 is_admin
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->boolean('is_admin')->default(false)->after('status');
|
||||
});
|
||||
|
||||
// 3. 資料遷移:針對現有租戶,將每一家公司最先建立的帳號(或是目前名稱為管理員角色的人)標記為 is_admin = true
|
||||
// 取得所有租戶公司 ID
|
||||
$companyIds = DB::table('companies')->pluck('id');
|
||||
|
||||
foreach ($companyIds as $companyId) {
|
||||
// 優先找該公司 ID 最小的 user (通常是第一個建立的)
|
||||
$userId = DB::table('users')
|
||||
->where('company_id', $companyId)
|
||||
->orderBy('id', 'asc')
|
||||
->value('id');
|
||||
|
||||
if ($userId) {
|
||||
DB::table('users')->where('id', $userId)->update(['is_admin' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('is_admin');
|
||||
});
|
||||
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->boolean('is_admin')->default(false)->after('is_system');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. 先將所有已刪除帳號的 is_admin 全部歸零,確保不會標記在「看不到的人」身上
|
||||
DB::table('users')->whereNotNull('deleted_at')->update(['is_admin' => false]);
|
||||
|
||||
// 2. 針對每一家公司,重新撈取「目前還存活 (deleted_at is null)」的最早建立帳號
|
||||
$companyIds = DB::table('companies')->pluck('id');
|
||||
|
||||
foreach ($companyIds as $companyId) {
|
||||
// 找該公司中,目前 ID 最小且「尚未被刪除」的 User
|
||||
$userId = DB::table('users')
|
||||
->where('company_id', $companyId)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id', 'asc')
|
||||
->value('id');
|
||||
|
||||
if ($userId) {
|
||||
// 將該帳號設為管理員,並確保該公司其它生存帳號如果是 true 的先清掉 (一對一標記)
|
||||
DB::table('users')
|
||||
->where('company_id', $companyId)
|
||||
->where('id', '!=', $userId)
|
||||
->update(['is_admin' => false]);
|
||||
|
||||
DB::table('users')->where('id', $userId)->update(['is_admin' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// 基本上這是資料修正,回復也不太有意義
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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('advertisements', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('company_id')->nullable()->constrained()->onDelete('cascade');
|
||||
$table->string('name');
|
||||
$table->string('type')->default('image'); // image, video
|
||||
$table->integer('duration')->default(15); // 15, 30, 60
|
||||
$table->string('url');
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('advertisements');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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('machine_advertisements', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('machine_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('advertisement_id')->constrained()->onDelete('cascade');
|
||||
$table->string('position')->comment('vending, visit_gift, standby');
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->dateTime('start_at')->nullable();
|
||||
$table->dateTime('end_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('machine_advertisements');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. 取得權限 ID
|
||||
$permission = DB::table('permissions')
|
||||
->where('name', 'menu.data-config.sub-account-roles')
|
||||
->first();
|
||||
|
||||
if ($permission) {
|
||||
// 2. 移除角色與該權限的關聯 (雖然 Spatie 通常會處理,但手動確保清理乾淨)
|
||||
DB::table('role_has_permissions')
|
||||
->where('permission_id', $permission->id)
|
||||
->delete();
|
||||
|
||||
// 3. 移除權限本身
|
||||
DB::table('permissions')
|
||||
->where('id', $permission->id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
// 4. 清理權限快取 (如果有的話)
|
||||
try {
|
||||
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
} catch (\Exception $e) {
|
||||
// 忽略快取清理失敗(例如在沒有 Redis 的環境中)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// 由於是要永久拿掉,down 邏輯通常不需要重建,
|
||||
// 若真要復原,應透過重跑 Seeder 或手動新增。
|
||||
}
|
||||
};
|
||||
@@ -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('remote_commands', function (Blueprint $table) {
|
||||
$table->string('note', 255)->nullable()->after('payload');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('remote_commands', function (Blueprint $table) {
|
||||
$table->dropColumn('note');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('remote_commands', function (Blueprint $table) {
|
||||
$table->foreignId('user_id')->nullable()->after('machine_id')->constrained()->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('remote_commands', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('user_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
{
|
||||
if (DB::getDriverName() !== 'sqlite') {
|
||||
DB::statement("ALTER TABLE remote_commands MODIFY COLUMN status ENUM('pending', 'sent', 'success', 'failed', 'superseded') DEFAULT 'pending'");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::getDriverName() !== 'sqlite') {
|
||||
DB::statement("ALTER TABLE remote_commands MODIFY COLUMN status ENUM('pending', 'sent', 'success', 'failed') DEFAULT 'pending'");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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('machines', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('machines', 'status')) {
|
||||
$table->dropColumn('status');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('machines', function (Blueprint $table) {
|
||||
$table->string('status')->default('offline')->after('location');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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'
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use App\Models\System\Role;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
@@ -21,18 +21,32 @@ class RoleSeeder extends Seeder
|
||||
$permissions = [
|
||||
'menu.members',
|
||||
'menu.machines',
|
||||
'menu.machines.list',
|
||||
'menu.machines.permissions',
|
||||
'menu.machines.utilization',
|
||||
'menu.machines.maintenance',
|
||||
'menu.app',
|
||||
'menu.warehouses',
|
||||
'menu.sales',
|
||||
'menu.analysis',
|
||||
'menu.audit',
|
||||
'menu.data-config',
|
||||
'menu.data-config.products',
|
||||
'menu.data-config.advertisements',
|
||||
'menu.data-config.sub-accounts',
|
||||
'menu.data-config.points',
|
||||
'menu.data-config.badges',
|
||||
'menu.remote',
|
||||
'menu.line',
|
||||
'menu.reservation',
|
||||
'menu.special-permission',
|
||||
'menu.basic-settings',
|
||||
'menu.basic.machines',
|
||||
'menu.basic.payment-configs',
|
||||
'menu.permissions',
|
||||
'menu.permissions.companies',
|
||||
'menu.permissions.accounts',
|
||||
'menu.permissions.roles',
|
||||
];
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
@@ -41,24 +55,33 @@ class RoleSeeder extends Seeder
|
||||
|
||||
// 建立角色
|
||||
$superAdmin = Role::updateOrCreate(
|
||||
['name' => 'super-admin'],
|
||||
['is_system' => true]
|
||||
['name' => 'super-admin', 'company_id' => null],
|
||||
['is_system' => true, 'guard_name' => 'web']
|
||||
);
|
||||
$superAdmin->syncPermissions(Permission::all());
|
||||
|
||||
$tenantAdmin = Role::updateOrCreate(
|
||||
['name' => '客戶管理員'],
|
||||
['is_system' => false]
|
||||
['name' => '客戶管理員角色模板', 'company_id' => null],
|
||||
['is_system' => true, 'guard_name' => 'web']
|
||||
);
|
||||
$tenantAdmin->syncPermissions([
|
||||
'menu.members',
|
||||
'menu.machines',
|
||||
'menu.machines.list',
|
||||
'menu.machines.permissions',
|
||||
'menu.machines.utilization',
|
||||
'menu.machines.maintenance',
|
||||
'menu.app',
|
||||
'menu.warehouses',
|
||||
'menu.sales',
|
||||
'menu.analysis',
|
||||
'menu.audit',
|
||||
'menu.data-config',
|
||||
'menu.data-config.products',
|
||||
'menu.data-config.advertisements',
|
||||
'menu.data-config.sub-accounts',
|
||||
'menu.data-config.points',
|
||||
'menu.data-config.badges',
|
||||
'menu.remote',
|
||||
'menu.line',
|
||||
'menu.reservation',
|
||||
|
||||
@@ -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)
|
||||
機台定時(建議每 5-10 秒)上送,用於確認連線狀態、溫度及門禁狀態。
|
||||
|
||||
|
||||
@@ -15,22 +15,34 @@ gantt
|
||||
excludes weekends
|
||||
|
||||
section Phase 1 基礎建設
|
||||
資料表 + IoT API + 異步管線 :active, p1, 2026-03-16, 5d
|
||||
資料表 + IoT API + 異步管線 :done, p1, 2026-03-16, 5d
|
||||
|
||||
section Phase 2 核心營運
|
||||
後台核心營運頁面整合 :p2, after p1, 50d
|
||||
帳號權限 + 資料主檔 + 機台 + 遠端 + 儀表板 :done, p2a, 2026-03-23, 30d
|
||||
MQTT 基礎架構 :active, mqtt, 2026-04-16, 3d
|
||||
全域工具列與溝通系統 :todo, toolbar, 2026-04-21, 4d
|
||||
銷售管理 :todo, p2d, after toolbar, 3d
|
||||
倉庫管理 :todo, p2f, after p2d, 6d
|
||||
|
||||
section Phase 3 進階模組
|
||||
進階分析與垂直模組 :p3, after p2, 30d
|
||||
進階分析與垂直模組 :todo, p3, after p2f, 15d
|
||||
```
|
||||
|
||||
## 二、詳細時程對照表
|
||||
|
||||
| 階段 (Phase) | 關鍵任務摘要 | 預估天數 | 預計工作日期 | 狀態 |
|
||||
| :--- | :--- | :---: | :---: | :---: |
|
||||
| **Phase 1** | 28 張資料表 Migration + B010~B710 核心 API + Redis 異步 Job | **5 工作天** | 03/16 ~ 03/20 | 進行中 |
|
||||
| **Phase 2** | **後台核心營運頁面** (帳號權限、資料設定、機台、銷售、遠端、倉庫、儀表板) | **50 工作天** | 03/23 ~ 05/29 | 規劃中 |
|
||||
| **Phase 3** | **進階垂直模組** (分析、稽核、會員、APP、Line、預約、特殊權限) | **30 工作天** | 06/01 ~ 07/10 | 規劃中 |
|
||||
| **Phase 1** | 資料表 Migration + IoT 核心 API + Redis 異步 Job | **5 天** | 03/16 ~ 03/20 | ✅ 完成 |
|
||||
| **Phase 2A** | 帳號與權限基礎 (帳號管理、子帳號、角色、RBAC) | **8 天** | 03/23 ~ 04/03 | ✅ 完成 |
|
||||
| **Phase 2B** | 基礎資料主檔 (商品、廣告、點數、識別證) | **5 天** | 04/06 ~ 04/10 | ✅ 完成 |
|
||||
| **Phase 2C** | 機台管理 (列表、日誌、權限、稼動率、效期、維修) | **7 天** | 04/13 ~ 04/23 | ✅ 完成 |
|
||||
| **Phase 2E** | 遠端管理 (庫存、重啟、出貨、鎖定等 7 項) | **8 天** | 04/30 ~ 05/11 | ✅ 完成 |
|
||||
| **Phase 2G** | 儀表板 | **2 天** | 05/28 ~ 05/29 | ✅ 完成 |
|
||||
| **MQTT** | EMQX + Go Gateway + Laravel 橋接 | **3 天** | 04/16 ~ 04/18 | 🔴 待開發 |
|
||||
| **全域工具列** | 下載中心、通知、帳號模擬、公告系統、快捷入口 | **4 天** | 04/21 ~ 04/24 | 🟡 待開發 |
|
||||
| **Phase 2D** | 銷售管理 (銷售紀錄、取貨碼、促銷、通行碼) | **3 天** | 04/25 ~ 04/29 | 🟢 待開發 |
|
||||
| **Phase 2F** | 倉庫管理 (倉庫、庫存、調撥、採購、補貨) | **6 天** | 04/30 ~ 05/07 | 🟢 待開發 |
|
||||
| **Phase 3** | 進階垂直模組 (分析、稽核、會員、APP、Line、預約) | **15 天** | 05/08 ~ 05/28 | 🔵 待開發 |
|
||||
|
||||
---
|
||||
|
||||
@@ -39,7 +51,7 @@ gantt
|
||||
> [!IMPORTANT]
|
||||
> 開發順序依**功能相依性**排列:先建帳號與權限基礎 → 再建商品等主檔資料 → 然後是依賴主檔的機台與銷售 → 接著是營運急需的遠端管理與倉庫管理 → 最後是匯總數據的儀表板。Phase 3 則從分析報表開始,逐步擴展至行銷與第三方聯動。
|
||||
|
||||
### ⚡ Phase 1:基礎建設 (03/16 ~ 03/20)
|
||||
### ⚡ Phase 1:基礎建設 (03/16 ~ 03/20) ✅ 已完成
|
||||
|
||||
| 任務類別 | 內容 | 日期 |
|
||||
| :--- | :--- | :---: |
|
||||
@@ -47,10 +59,27 @@ gantt
|
||||
| IoT API 端點 | B010 心跳、B017 庫存、B055 出貨、B600/B601/B602 金流 | 03/18 - 03/19 |
|
||||
| 異步管線 | Redis Queue Job + Service 層、B650 會員驗證 | 03/20 |
|
||||
|
||||
### 🔌 MQTT 基礎架構 (04/16 ~ 04/18) 🔴 待開發
|
||||
|
||||
| 日期 | 任務 |
|
||||
| :---: | :--- |
|
||||
| **04/16 (三)** | EMQX 佈署至 compose.yaml + Go Gateway 上行開發 |
|
||||
| **04/17 (四)** | Go Gateway 上行完成 + 下行開發 |
|
||||
| **04/18 (五)** | Laravel mqtt:listen + MqttCommandService + 端對端測試 |
|
||||
|
||||
### 🛠️ 全域工具列與溝通系統 (04/21 ~ 04/24) 🟡 待開發
|
||||
|
||||
| 日期 | 任務 |
|
||||
| :---: | :--- |
|
||||
| **04/21 (一)** | ☁️ 下載任務中心 |
|
||||
| **04/22 (二)** | 🔔 通知中心 + ❓ 幫助/客服中心 |
|
||||
| **04/23 (三)** | 🎭 帳號切換與身分模擬 + 📢 系統公告管理 |
|
||||
| **04/24 (四)** | 🛡️ 登錄強制公告 + 🚀 儀表板快捷入口 |
|
||||
|
||||
### 🏛️ Phase 2:核心營運子選單 (03/23 ~ 05/29)
|
||||
共 51 項子選單,依功能相依性分為七個開發階段。
|
||||
|
||||
#### 📌 A. 帳號與權限基礎 (03/23 ~ 04/03)
|
||||
#### 📌 A. 帳號與權限基礎 (03/23 ~ 04/03) ✅ 已完成
|
||||
> 為何優先:帳號、角色、權限是所有後台模組的存取控管基礎,必須最先到位。
|
||||
|
||||
| # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 |
|
||||
@@ -72,7 +101,7 @@ gantt
|
||||
| 15 | | 其他功能 | 04/03 | 其餘未分類功能之權限控管 |
|
||||
| 16 | | AI智能預測 | 04/03 | AI 預測功能的存取權限設定 |
|
||||
|
||||
#### 📌 B. 基礎資料主檔 (04/06 ~ 04/10)
|
||||
#### 📌 B. 基礎資料主檔 (04/06 ~ 04/10) ✅ 已完成
|
||||
> 為何第二:商品主檔是機台貨道、倉庫、銷售等後續模組的共同基礎資料。
|
||||
|
||||
| # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 |
|
||||
@@ -83,7 +112,7 @@ gantt
|
||||
| 20 | | 點數設定 | 04/10 | 點數發放規則與兌換比例設定 |
|
||||
| 21 | | 識別證 | 04/10 | 員工/維修人員識別證管理 |
|
||||
|
||||
#### 📌 C. 機台管理 (04/13 ~ 04/23)
|
||||
#### 📌 C. 機台管理 (04/13 ~ 04/23) ✅ 已完成
|
||||
> 為何第三:機台是核心營運實體,須在商品主檔建好後才能綁定貨道。
|
||||
|
||||
| # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 |
|
||||
@@ -107,7 +136,7 @@ gantt
|
||||
| 32 | | 通行碼 | 04/29 | 通行碼發放與使用紀錄 |
|
||||
| 33 | | 來店禮 | 04/29 | 到店即贈的禮品活動設定 |
|
||||
|
||||
#### 📌 E. 遠端管理 (04/30 ~ 05/11)
|
||||
#### 📌 E. 遠端管理 (04/30 ~ 05/11) ✅ 已完成
|
||||
> 為何第五:營運最迫切需要的即時控制能力,直接串接 Phase 1 的 B010/B055 API。
|
||||
|
||||
| # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 |
|
||||
@@ -136,7 +165,7 @@ gantt
|
||||
| 49 | | 人員庫存 | 05/27 | 補貨人員攜帶庫存管理 |
|
||||
| 50 | | 回庫單 | 05/27 | 退回倉庫的商品登記與核銷 |
|
||||
|
||||
#### 📌 G. 儀表板 (05/28 ~ 05/29)
|
||||
#### 📌 G. 儀表板 (05/28 ~ 05/29) ✅ 已完成
|
||||
> 為何最後:儀表板匯總機台、銷售、遠端指令、倉庫等全部數據,必須等上游模組完成才有意義。
|
||||
|
||||
| # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 |
|
||||
|
||||
43
docs/future_todo.md
Normal file
43
docs/future_todo.md
Normal 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 週期內執行耗時匯出。
|
||||
191
docs/mqtt-implementation-plan.md
Normal file
191
docs/mqtt-implementation-plan.md
Normal 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 的處理速率
|
||||
1506
lang/en.json
1506
lang/en.json
File diff suppressed because it is too large
Load Diff
1528
lang/ja.json
1528
lang/ja.json
File diff suppressed because it is too large
Load Diff
1554
lang/zh_TW.json
1554
lang/zh_TW.json
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,8 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => 'These credentials do not match our records.',
|
||||
'password' => 'The provided password is incorrect.',
|
||||
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
|
||||
'failed' => '帳號或密碼錯誤,請重新確認。',
|
||||
'password' => '您輸入的密碼不正確。',
|
||||
'throttle' => '登入嘗試次數過多。請在 :seconds 秒後再試。',
|
||||
|
||||
];
|
||||
|
||||
@@ -13,159 +13,159 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'accepted' => 'The :attribute field must be accepted.',
|
||||
'accepted_if' => 'The :attribute field must be accepted when :other is :value.',
|
||||
'active_url' => 'The :attribute field must be a valid URL.',
|
||||
'after' => 'The :attribute field must be a date after :date.',
|
||||
'after_or_equal' => 'The :attribute field must be a date after or equal to :date.',
|
||||
'alpha' => 'The :attribute field must only contain letters.',
|
||||
'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.',
|
||||
'alpha_num' => 'The :attribute field must only contain letters and numbers.',
|
||||
'any_of' => 'The :attribute field is invalid.',
|
||||
'array' => 'The :attribute field must be an array.',
|
||||
'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',
|
||||
'before' => 'The :attribute field must be a date before :date.',
|
||||
'before_or_equal' => 'The :attribute field must be a date before or equal to :date.',
|
||||
'accepted' => '必須接受 :attribute。',
|
||||
'accepted_if' => '當 :other 為 :value 時,必須接受 :attribute。',
|
||||
'active_url' => ':attribute 並非有效的 URL。',
|
||||
'after' => ':attribute 必須在 :date 之後。',
|
||||
'after_or_equal' => ':attribute 必須在 :date 之後或相等。',
|
||||
'alpha' => ':attribute 只能包含字母。',
|
||||
'alpha_dash' => ':attribute 只能包含字母、數字、破折號與底線。',
|
||||
'alpha_num' => ':attribute 只能包含字母與數字。',
|
||||
'any_of' => ':attribute 無效。',
|
||||
'array' => ':attribute 必須是一個陣列。',
|
||||
'ascii' => ':attribute 只能包含單字節的字母、數字與符號。',
|
||||
'before' => ':attribute 必須在 :date 之前。',
|
||||
'before_or_equal' => ':attribute 必須在 :date 之前或相等。',
|
||||
'between' => [
|
||||
'array' => 'The :attribute field must have between :min and :max items.',
|
||||
'file' => 'The :attribute field must be between :min and :max kilobytes.',
|
||||
'numeric' => 'The :attribute field must be between :min and :max.',
|
||||
'string' => 'The :attribute field must be between :min and :max characters.',
|
||||
'array' => ':attribute 必須包含 :min 至 :max 個項目。',
|
||||
'file' => ':attribute 必須介於 :min 至 :max KB 之間。',
|
||||
'numeric' => ':attribute 必須介於 :min 至 :max 之間。',
|
||||
'string' => ':attribute 必須介於 :min 至 :max 個字元之間。',
|
||||
],
|
||||
'boolean' => 'The :attribute field must be true or false.',
|
||||
'can' => 'The :attribute field contains an unauthorized value.',
|
||||
'confirmed' => 'The :attribute field confirmation does not match.',
|
||||
'contains' => 'The :attribute field is missing a required value.',
|
||||
'current_password' => 'The password is incorrect.',
|
||||
'date' => 'The :attribute field must be a valid date.',
|
||||
'date_equals' => 'The :attribute field must be a date equal to :date.',
|
||||
'date_format' => 'The :attribute field must match the format :format.',
|
||||
'decimal' => 'The :attribute field must have :decimal decimal places.',
|
||||
'declined' => 'The :attribute field must be declined.',
|
||||
'declined_if' => 'The :attribute field must be declined when :other is :value.',
|
||||
'different' => 'The :attribute field and :other must be different.',
|
||||
'digits' => 'The :attribute field must be :digits digits.',
|
||||
'digits_between' => 'The :attribute field must be between :min and :max digits.',
|
||||
'dimensions' => 'The :attribute field has invalid image dimensions.',
|
||||
'distinct' => 'The :attribute field has a duplicate value.',
|
||||
'doesnt_contain' => 'The :attribute field must not contain any of the following: :values.',
|
||||
'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.',
|
||||
'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.',
|
||||
'email' => 'The :attribute field must be a valid email address.',
|
||||
'encoding' => 'The :attribute field must be encoded in :encoding.',
|
||||
'ends_with' => 'The :attribute field must end with one of the following: :values.',
|
||||
'enum' => 'The selected :attribute is invalid.',
|
||||
'exists' => 'The selected :attribute is invalid.',
|
||||
'extensions' => 'The :attribute field must have one of the following extensions: :values.',
|
||||
'file' => 'The :attribute field must be a file.',
|
||||
'filled' => 'The :attribute field must have a value.',
|
||||
'boolean' => ':attribute 必須為布林值。',
|
||||
'can' => ':attribute 包含未授權的值。',
|
||||
'confirmed' => ':attribute 確認欄位不符。',
|
||||
'contains' => ':attribute 缺少必要的值。',
|
||||
'current_password' => '目前的密碼不正確。',
|
||||
'date' => ':attribute 並非有效的日期。',
|
||||
'date_equals' => ':attribute 必須等於 :date。',
|
||||
'date_format' => ':attribute 不符合格式 :format。',
|
||||
'decimal' => ':attribute 必須有 :decimal 位小數。',
|
||||
'declined' => ':attribute 必須拒絕。',
|
||||
'declined_if' => '當 :other 為 :value 時,:attribute 必須拒絕。',
|
||||
'different' => ':attribute 與 :other 必須不同。',
|
||||
'digits' => ':attribute 必須是 :digits 位數。',
|
||||
'digits_between' => ':attribute 必須介於 :min 至 :max 位數之間。',
|
||||
'dimensions' => ':attribute 圖片尺寸無效。',
|
||||
'distinct' => ':attribute 欄位含有重複的值。',
|
||||
'doesnt_contain' => ':attribute 不得包含以下任何值::values。',
|
||||
'doesnt_end_with' => ':attribute 不得以以下任何值結尾::values。',
|
||||
'doesnt_start_with' => ':attribute 不得以以下任何值開頭::values。',
|
||||
'email' => ':attribute 必須是有效的電子郵件地址。',
|
||||
'encoding' => ':attribute 必須以 :encoding 編碼。',
|
||||
'ends_with' => ':attribute 必須以以下任一值結尾::values。',
|
||||
'enum' => '所選的 :attribute 無效。',
|
||||
'exists' => '所選的 :attribute 無效。',
|
||||
'extensions' => ':attribute 必須是以下副檔名之一::values。',
|
||||
'file' => ':attribute 必須是一個檔案。',
|
||||
'filled' => ':attribute 不能為空。',
|
||||
'gt' => [
|
||||
'array' => 'The :attribute field must have more than :value items.',
|
||||
'file' => 'The :attribute field must be greater than :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be greater than :value.',
|
||||
'string' => 'The :attribute field must be greater than :value characters.',
|
||||
'array' => ':attribute 必須包含超過 :value 個項目。',
|
||||
'file' => ':attribute 必須大於 :value KB。',
|
||||
'numeric' => ':attribute 必須大於 :value。',
|
||||
'string' => ':attribute 必須超過 :value 個字元。',
|
||||
],
|
||||
'gte' => [
|
||||
'array' => 'The :attribute field must have :value items or more.',
|
||||
'file' => 'The :attribute field must be greater than or equal to :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be greater than or equal to :value.',
|
||||
'string' => 'The :attribute field must be greater than or equal to :value characters.',
|
||||
'array' => ':attribute 必須包含 :value 個以上項目。',
|
||||
'file' => ':attribute 必須大於或等於 :value KB。',
|
||||
'numeric' => ':attribute 必須大於或等於 :value。',
|
||||
'string' => ':attribute 必須大於或等於 :value 個字元。',
|
||||
],
|
||||
'hex_color' => 'The :attribute field must be a valid hexadecimal color.',
|
||||
'image' => 'The :attribute field must be an image.',
|
||||
'in' => 'The selected :attribute is invalid.',
|
||||
'in_array' => 'The :attribute field must exist in :other.',
|
||||
'in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.',
|
||||
'integer' => 'The :attribute field must be an integer.',
|
||||
'ip' => 'The :attribute field must be a valid IP address.',
|
||||
'ipv4' => 'The :attribute field must be a valid IPv4 address.',
|
||||
'ipv6' => 'The :attribute field must be a valid IPv6 address.',
|
||||
'json' => 'The :attribute field must be a valid JSON string.',
|
||||
'list' => 'The :attribute field must be a list.',
|
||||
'lowercase' => 'The :attribute field must be lowercase.',
|
||||
'hex_color' => ':attribute 必須是有效的十六進位色碼。',
|
||||
'image' => ':attribute 必須是一張圖片。',
|
||||
'in' => '所選的 :attribute 無效。',
|
||||
'in_array' => ':attribute 必須存在於 :other 之中。',
|
||||
'in_array_keys' => ':attribute 必須包含以下至少一個鍵::values。',
|
||||
'integer' => ':attribute 必須是整數。',
|
||||
'ip' => ':attribute 必須是有效的 IP 位址。',
|
||||
'ipv4' => ':attribute 必須是有效的 IPv4 位址。',
|
||||
'ipv6' => ':attribute 必須是有效的 IPv6 位址。',
|
||||
'json' => ':attribute 必須是有效的 JSON 字串。',
|
||||
'list' => ':attribute 必須是一個列表。',
|
||||
'lowercase' => ':attribute 必須是小寫。',
|
||||
'lt' => [
|
||||
'array' => 'The :attribute field must have less than :value items.',
|
||||
'file' => 'The :attribute field must be less than :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be less than :value.',
|
||||
'string' => 'The :attribute field must be less than :value characters.',
|
||||
'array' => ':attribute 必須包含少於 :value 個項目。',
|
||||
'file' => ':attribute 必須小於 :value KB。',
|
||||
'numeric' => ':attribute 必須小於 :value。',
|
||||
'string' => ':attribute 必須少於 :value 個字元。',
|
||||
],
|
||||
'lte' => [
|
||||
'array' => 'The :attribute field must not have more than :value items.',
|
||||
'file' => 'The :attribute field must be less than or equal to :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be less than or equal to :value.',
|
||||
'string' => 'The :attribute field must be less than or equal to :value characters.',
|
||||
'array' => ':attribute 不得包含超過 :value 個項目。',
|
||||
'file' => ':attribute 必須小於或等於 :value KB。',
|
||||
'numeric' => ':attribute 必須小於或等於 :value。',
|
||||
'string' => ':attribute 必須小於或等於 :value 個字元。',
|
||||
],
|
||||
'mac_address' => 'The :attribute field must be a valid MAC address.',
|
||||
'mac_address' => ':attribute 必須是有效的 MAC 位址。',
|
||||
'max' => [
|
||||
'array' => 'The :attribute field must not have more than :max items.',
|
||||
'file' => 'The :attribute field must not be greater than :max kilobytes.',
|
||||
'numeric' => 'The :attribute field must not be greater than :max.',
|
||||
'string' => 'The :attribute field must not be greater than :max characters.',
|
||||
'array' => ':attribute 不得超過 :max 個項目。',
|
||||
'file' => ':attribute 不得大於 :max KB。',
|
||||
'numeric' => ':attribute 不得大於 :max。',
|
||||
'string' => ':attribute 不得超過 :max 個字元。',
|
||||
],
|
||||
'max_digits' => 'The :attribute field must not have more than :max digits.',
|
||||
'mimes' => 'The :attribute field must be a file of type: :values.',
|
||||
'mimetypes' => 'The :attribute field must be a file of type: :values.',
|
||||
'max_digits' => ':attribute 不得超過 :max 位數。',
|
||||
'mimes' => ':attribute 必須是以下檔案類型::values。',
|
||||
'mimetypes' => ':attribute 必須是以下檔案類型::values。',
|
||||
'min' => [
|
||||
'array' => 'The :attribute field must have at least :min items.',
|
||||
'file' => 'The :attribute field must be at least :min kilobytes.',
|
||||
'numeric' => 'The :attribute field must be at least :min.',
|
||||
'string' => 'The :attribute field must be at least :min characters.',
|
||||
'array' => ':attribute 至少需要 :min 個項目。',
|
||||
'file' => ':attribute 至少需要 :min KB。',
|
||||
'numeric' => ':attribute 不得小於 :min。',
|
||||
'string' => ':attribute 至少需要 :min 個字元。',
|
||||
],
|
||||
'min_digits' => 'The :attribute field must have at least :min digits.',
|
||||
'missing' => 'The :attribute field must be missing.',
|
||||
'missing_if' => 'The :attribute field must be missing when :other is :value.',
|
||||
'missing_unless' => 'The :attribute field must be missing unless :other is :value.',
|
||||
'missing_with' => 'The :attribute field must be missing when :values is present.',
|
||||
'missing_with_all' => 'The :attribute field must be missing when :values are present.',
|
||||
'multiple_of' => 'The :attribute field must be a multiple of :value.',
|
||||
'not_in' => 'The selected :attribute is invalid.',
|
||||
'not_regex' => 'The :attribute field format is invalid.',
|
||||
'numeric' => 'The :attribute field must be a number.',
|
||||
'min_digits' => ':attribute 至少需要 :min 位數。',
|
||||
'missing' => ':attribute 必須不存在。',
|
||||
'missing_if' => '當 :other 為 :value 時,:attribute 必須不存在。',
|
||||
'missing_unless' => '除非 :other 為 :value,否則 :attribute 必須不存在。',
|
||||
'missing_with' => '當 :values 存在時,:attribute 必須不存在。',
|
||||
'missing_with_all' => '當 :values 都存在時,:attribute 必須不存在。',
|
||||
'multiple_of' => ':attribute 必須是 :value 的倍數。',
|
||||
'not_in' => '所選的 :attribute 無效。',
|
||||
'not_regex' => ':attribute 格式無效。',
|
||||
'numeric' => ':attribute 必須是數字。',
|
||||
'password' => [
|
||||
'letters' => 'The :attribute field must contain at least one letter.',
|
||||
'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.',
|
||||
'numbers' => 'The :attribute field must contain at least one number.',
|
||||
'symbols' => 'The :attribute field must contain at least one symbol.',
|
||||
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',
|
||||
'letters' => ':attribute 必須包含至少一個字母。',
|
||||
'mixed' => ':attribute 必須包含至少一個大寫與一個小寫字母。',
|
||||
'numbers' => ':attribute 必須包含至少一個數字。',
|
||||
'symbols' => ':attribute 必須包含至少一個符號。',
|
||||
'uncompromised' => ':attribute 已出現在外洩資料中,請選擇其他 :attribute。',
|
||||
],
|
||||
'present' => 'The :attribute field must be present.',
|
||||
'present_if' => 'The :attribute field must be present when :other is :value.',
|
||||
'present_unless' => 'The :attribute field must be present unless :other is :value.',
|
||||
'present_with' => 'The :attribute field must be present when :values is present.',
|
||||
'present_with_all' => 'The :attribute field must be present when :values are present.',
|
||||
'prohibited' => 'The :attribute field is prohibited.',
|
||||
'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
|
||||
'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.',
|
||||
'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.',
|
||||
'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
|
||||
'prohibits' => 'The :attribute field prohibits :other from being present.',
|
||||
'regex' => 'The :attribute field format is invalid.',
|
||||
'required' => 'The :attribute field is required.',
|
||||
'required_array_keys' => 'The :attribute field must contain entries for: :values.',
|
||||
'required_if' => 'The :attribute field is required when :other is :value.',
|
||||
'required_if_accepted' => 'The :attribute field is required when :other is accepted.',
|
||||
'required_if_declined' => 'The :attribute field is required when :other is declined.',
|
||||
'required_unless' => 'The :attribute field is required unless :other is in :values.',
|
||||
'required_with' => 'The :attribute field is required when :values is present.',
|
||||
'required_with_all' => 'The :attribute field is required when :values are present.',
|
||||
'required_without' => 'The :attribute field is required when :values is not present.',
|
||||
'required_without_all' => 'The :attribute field is required when none of :values are present.',
|
||||
'same' => 'The :attribute field must match :other.',
|
||||
'present' => ':attribute 必須存在。',
|
||||
'present_if' => '當 :other 為 :value 時,:attribute 必須存在。',
|
||||
'present_unless' => '除非 :other 為 :value,否則 :attribute 必須存在。',
|
||||
'present_with' => '當 :values 存在時,:attribute 必須存在。',
|
||||
'present_with_all' => '當 :values 都存在時,:attribute 必須存在。',
|
||||
'prohibited' => ':attribute 被禁止使用。',
|
||||
'prohibited_if' => '當 :other 為 :value 時,:attribute 被禁止使用。',
|
||||
'prohibited_if_accepted' => '當 :other 被接受時,:attribute 被禁止使用。',
|
||||
'prohibited_if_declined' => '當 :other 被拒絕時,:attribute 被禁止使用。',
|
||||
'prohibited_unless' => '除非 :other 在 :values 之中,否則 :attribute 被禁止使用。',
|
||||
'prohibits' => ':attribute 禁止 :other 存在。',
|
||||
'regex' => ':attribute 格式無效。',
|
||||
'required' => ':attribute 為必填欄位。',
|
||||
'required_array_keys' => ':attribute 必須包含以下項目::values。',
|
||||
'required_if' => '當 :other 為 :value 時,:attribute 為必填。',
|
||||
'required_if_accepted' => '當 :other 被接受時,:attribute 為必填。',
|
||||
'required_if_declined' => '當 :other 被拒絕時,:attribute 為必填。',
|
||||
'required_unless' => '除非 :other 在 :values 之中,否則 :attribute 為必填。',
|
||||
'required_with' => '當 :values 存在時,:attribute 為必填。',
|
||||
'required_with_all' => '當 :values 都存在時,:attribute 為必填。',
|
||||
'required_without' => '當 :values 不存在時,:attribute 為必填。',
|
||||
'required_without_all' => '當 :values 都不存在時,:attribute 為必填。',
|
||||
'same' => ':attribute 必須與 :other 相符。',
|
||||
'size' => [
|
||||
'array' => 'The :attribute field must contain :size items.',
|
||||
'file' => 'The :attribute field must be :size kilobytes.',
|
||||
'numeric' => 'The :attribute field must be :size.',
|
||||
'string' => 'The :attribute field must be :size characters.',
|
||||
'array' => ':attribute 必須包含 :size 個項目。',
|
||||
'file' => ':attribute 必須是 :size KB。',
|
||||
'numeric' => ':attribute 必須是 :size。',
|
||||
'string' => ':attribute 必須是 :size 個字元。',
|
||||
],
|
||||
'starts_with' => 'The :attribute field must start with one of the following: :values.',
|
||||
'string' => 'The :attribute field must be a string.',
|
||||
'timezone' => 'The :attribute field must be a valid timezone.',
|
||||
'unique' => 'The :attribute has already been taken.',
|
||||
'uploaded' => 'The :attribute failed to upload.',
|
||||
'uppercase' => 'The :attribute field must be uppercase.',
|
||||
'url' => 'The :attribute field must be a valid URL.',
|
||||
'ulid' => 'The :attribute field must be a valid ULID.',
|
||||
'uuid' => 'The :attribute field must be a valid UUID.',
|
||||
'starts_with' => ':attribute 必須以以下任一值開頭::values。',
|
||||
'string' => ':attribute 必須是字串。',
|
||||
'timezone' => ':attribute 必須是有效的時區。',
|
||||
'unique' => ':attribute 已被使用。',
|
||||
'uploaded' => ':attribute 上傳失敗。',
|
||||
'uppercase' => ':attribute 必須是大寫。',
|
||||
'url' => ':attribute 必須是有效的 URL。',
|
||||
'ulid' => ':attribute 必須是有效的 ULID。',
|
||||
'uuid' => ':attribute 必須是有效的 UUID。',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -179,8 +179,8 @@ return [
|
||||
*/
|
||||
|
||||
'custom' => [
|
||||
'attribute-name' => [
|
||||
'rule-name' => 'custom-message',
|
||||
'is_confirmed' => [
|
||||
'accepted' => '您必須勾選確認已告知客戶並取得簽名。',
|
||||
],
|
||||
],
|
||||
|
||||
@@ -195,6 +195,19 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'attributes' => [],
|
||||
'attributes' => [
|
||||
'username' => '帳號',
|
||||
'name' => '姓名',
|
||||
'email' => '電子郵件',
|
||||
'password' => '密碼',
|
||||
'current_password' => '目前密碼',
|
||||
'password_confirmation' => '確認密碼',
|
||||
'phone' => '電話',
|
||||
'machine_id' => '機台',
|
||||
'category' => '類別',
|
||||
'maintenance_at' => '維修日期',
|
||||
'content' => '維修內容',
|
||||
'is_confirmed' => '確認勾選框',
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
171
package-lock.json
generated
171
package-lock.json
generated
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"name": "html",
|
||||
"name": "star-cloud",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"flatpickr": "^4.6.13",
|
||||
"pptxgenjs": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@alpinejs/collapse": "^3.15.3",
|
||||
"@tailwindcss/forms": "^0.5.2",
|
||||
@@ -871,6 +875,7 @@
|
||||
"integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Fuzzyma"
|
||||
@@ -896,6 +901,7 @@
|
||||
"integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18"
|
||||
},
|
||||
@@ -930,6 +936,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
||||
@@ -1123,6 +1138,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.25",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
@@ -1243,6 +1259,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -1485,6 +1507,12 @@
|
||||
"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": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
@@ -1669,6 +1697,39 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/https": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
|
||||
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
|
||||
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"queue": "6.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"image-size": "bin/image-size.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -1731,12 +1792,19 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -1748,6 +1816,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/just-extend": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/just-extend/-/just-extend-5.1.1.tgz",
|
||||
@@ -1775,6 +1855,15 @@
|
||||
"vite": "^5.0.0 || ^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -1947,6 +2036,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -2014,6 +2109,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -2157,6 +2253,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pptxgenjs": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz",
|
||||
"integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^22.8.1",
|
||||
"https": "^1.0.0",
|
||||
"image-size": "^1.2.1",
|
||||
"jszip": "^3.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/preline": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/preline/-/preline-3.2.3.tgz",
|
||||
@@ -2172,6 +2280,12 @@
|
||||
"vanilla-calendar-pro": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -2179,6 +2293,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -2210,6 +2333,21 @@
|
||||
"pify": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -2321,6 +2459,18 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2331,6 +2481,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
@@ -2373,6 +2532,7 @@
|
||||
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -2469,6 +2629,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2496,6 +2657,12 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
|
||||
@@ -2531,7 +2698,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vanilla-calendar-pro": {
|
||||
@@ -2551,6 +2717,7 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
||||
@@ -17,5 +17,9 @@
|
||||
"preline": "^3.2.3",
|
||||
"tailwindcss": "^3.1.0",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"flatpickr": "^4.6.13",
|
||||
"pptxgenjs": "^4.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
135
pptx_gen.cjs
Normal file
135
pptx_gen.cjs
Normal file
@@ -0,0 +1,135 @@
|
||||
const pptxgen = require("pptxgenjs");
|
||||
const fs = require("fs");
|
||||
|
||||
const pres = new pptxgen();
|
||||
pres.layout = "LAYOUT_16x9";
|
||||
pres.title = "Star Cloud Demo Day - Technical Edition";
|
||||
pres.author = "許家偉";
|
||||
|
||||
// --- Theme Colors ---
|
||||
const COLORS = {
|
||||
bg: "0F172A",
|
||||
cardBg: "1E293B",
|
||||
highlight: "2DD4BF",
|
||||
secondary: "7DD3FC",
|
||||
text: "FFFFFF",
|
||||
muted: "94A3B8"
|
||||
};
|
||||
|
||||
const FONTS = {
|
||||
header: "Georgia",
|
||||
body: "Calibri"
|
||||
};
|
||||
|
||||
const makeShadow = (opacity = 0.2) => ({
|
||||
type: "outer",
|
||||
blur: 8,
|
||||
offset: 3,
|
||||
angle: 135,
|
||||
color: "000000",
|
||||
opacity: opacity
|
||||
});
|
||||
|
||||
// --- Slide 1: Title ---
|
||||
const slide1 = pres.addSlide();
|
||||
slide1.background = { color: COLORS.bg };
|
||||
slide1.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 0.15, h: "100%", fill: { color: COLORS.highlight } });
|
||||
slide1.addText("Star Cloud Demo Day", { x: 1.0, y: 1.8, w: 8, h: 1, fontSize: 48, fontFace: FONTS.header, color: COLORS.text, bold: true, margin: 0 });
|
||||
slide1.addText("2026-03-25 ~ 2026-03-31 成果發表", { x: 1.0, y: 2.8, w: 8, h: 0.5, fontSize: 24, fontFace: FONTS.body, color: COLORS.secondary, italic: true });
|
||||
slide1.addShape(pres.shapes.RECTANGLE, { x: 7.5, y: 4.5, w: 2, h: 0.6, fill: { color: COLORS.highlight, transparency: 10 }, shadow: makeShadow() });
|
||||
slide1.addText("演講者:許家偉", { x: 7.5, y: 4.5, w: 2, h: 0.6, fontSize: 14, fontFace: FONTS.body, color: COLORS.text, align: "center", valign: "middle", bold: true });
|
||||
|
||||
// --- Slide 2: Overview ---
|
||||
const slide2 = pres.addSlide();
|
||||
slide2.background = { color: COLORS.bg };
|
||||
slide2.addText("本週進度總覽 / Overview", { x: 0.5, y: 0.4, w: 9, h: 0.6, fontSize: 32, fontFace: FONTS.header, color: COLORS.highlight, bold: true });
|
||||
slide2.addShape(pres.shapes.LINE, { x: 2, y: 3, w: 6, h: 0, line: { color: COLORS.highlight, width: 4 } });
|
||||
const timelineItems = [
|
||||
{ title: "機台權限管理", desc: "模組獨立化與效能優化" },
|
||||
{ title: "商品管理整合", desc: "狀態整合與多語系修復" },
|
||||
{ title: "廣告隔離機制", desc: "多租戶素材歸屬強化" }
|
||||
];
|
||||
timelineItems.forEach((item, idx) => {
|
||||
const x = 2 + (idx * 3);
|
||||
slide2.addShape(pres.shapes.OVAL, { x: x - 0.2, y: 2.8, w: 0.4, h: 0.4, fill: { color: COLORS.secondary }, line: { color: COLORS.text, width: 2 } });
|
||||
slide2.addText(item.title, { x: x - 1, y: 3.3, w: 2, h: 0.4, fontSize: 18, fontFace: FONTS.header, color: COLORS.text, align: "center", bold: true });
|
||||
slide2.addText(item.desc, { x: x - 1, y: 3.7, w: 2, h: 0.6, fontSize: 12, fontFace: FONTS.body, color: COLORS.muted, align: "center" });
|
||||
});
|
||||
|
||||
// --- Slide 3: Highlight 1 - Machine Permissions (WITH EAGER LOADING) ---
|
||||
const slide3 = pres.addSlide();
|
||||
slide3.background = { color: COLORS.bg };
|
||||
slide3.addText("亮點一:機台權限管理獨立化", { x: 0.5, y: 0.4, w: 9, h: 0.6, fontSize: 28, fontFace: FONTS.header, color: COLORS.highlight, bold: true });
|
||||
|
||||
const features = [
|
||||
{ title: "直覺檢索", text: "管理員可快速檢索、分配所屬帳號對應的機台清單。" },
|
||||
{ title: "即時過濾", text: "透過 Alpine.js 實作,輸入關鍵字即動態無刷新過濾。" },
|
||||
{ title: "效能關鍵:Eager Loading", text: "使用 with('machines') 解決 N+1 問題,萬筆資料秒開。" }
|
||||
];
|
||||
|
||||
features.forEach((f, idx) => {
|
||||
const y = 1.2 + (idx * 1.3);
|
||||
slide3.addShape(pres.shapes.RECTANGLE, { x: 0.5, y: y, w: 4, h: 1.1, fill: { color: COLORS.cardBg }, line: { color: COLORS.secondary, width: 1 }, shadow: makeShadow() });
|
||||
slide3.addText(f.title, { x: 0.7, y: y + 0.1, w: 3.5, h: 0.3, fontSize: 16, fontFace: FONTS.header, color: COLORS.highlight, bold: true });
|
||||
slide3.addText(f.text, { x: 0.7, y: y + 0.45, w: 3.5, h: 0.5, fontSize: 12, fontFace: FONTS.body, color: COLORS.text });
|
||||
});
|
||||
|
||||
slide3.addImage({
|
||||
path: "/home/mama/.gemini/antigravity/brain/58a170d0-7144-4e9f-9396-3e753a0bf69a/machine_permissions_table_1774944648298.png",
|
||||
x: 4.8, y: 1.2, w: 4.8, h: 3.7, sizing: { type: 'contain' }, shadow: makeShadow(0.3)
|
||||
});
|
||||
|
||||
// --- Slide 4: Highlight 2 ---
|
||||
const slide4 = pres.addSlide();
|
||||
slide4.background = { color: COLORS.bg };
|
||||
slide4.addText("亮點二:商品管理整合與 UX 完善", { x: 0.5, y: 0.4, w: 9, h: 0.6, fontSize: 28, fontFace: FONTS.header, color: COLORS.highlight, bold: true });
|
||||
const comparison = [
|
||||
{ title: "功能整合 (Integrated)", items: ["• 「商品狀態」納入主流程", "• 減少跳轉,提升管理效率"] },
|
||||
{ title: "細節優化 (Enhanced)", items: ["• 修復多語系(ZH/EN/JA)存取故障", "• 密碼欄位新增顯隱切換按鈕"] }
|
||||
];
|
||||
comparison.forEach((c, idx) => {
|
||||
const x = 0.5 + (idx * 4.75);
|
||||
slide4.addShape(pres.shapes.RECTANGLE, { x: x, y: 1.2, w: 4.25, h: 3.5, fill: { color: COLORS.cardBg }, line: { color: COLORS.highlight, width: 2 }, shadow: makeShadow(0.2) });
|
||||
slide4.addText(c.title, { x: x + 0.2, y: 1.4, w: 3.8, h: 0.4, fontSize: 18, fontFace: FONTS.header, color: COLORS.highlight, bold: true, align: "center" });
|
||||
slide4.addText(c.items.map(i => ({ text: i, options: { breakLine: true, bullet: true } })), { x: x + 0.3, y: 2.0, w: 3.6, h: 2.5, fontSize: 14, fontFace: FONTS.body, color: COLORS.text, margin: 0 });
|
||||
});
|
||||
|
||||
// --- Slide 5: Highlight 3 ---
|
||||
const slide5 = pres.addSlide();
|
||||
slide5.background = { color: COLORS.bg };
|
||||
slide5.addText("亮點三:多租戶廣告素材隔離機制", { x: 1.0, y: 0.4, w: 8, h: 1, fontSize: 32, fontFace: FONTS.header, color: COLORS.highlight, bold: true, align: "center" });
|
||||
slide5.addShape(pres.shapes.OVAL, { x: 4.25, y: 2.0, w: 1.5, h: 1.5, fill: { color: COLORS.highlight, transparency: 30 }, line: { color: COLORS.secondary, width: 2 } });
|
||||
slide5.addText("安全性隔離\n(Shield)", { x: 4.25, y: 2.5, w: 1.5, h: 0.5, fontSize: 14, fontFace: FONTS.header, color: COLORS.text, align: "center", bold: true });
|
||||
const isolations = [
|
||||
{ x: 1, y: 1.8, label: "公司 A\n廣告素材", color: "A8DADC" },
|
||||
{ x: 7.5, y: 1.8, label: "公司 B\n廣告素材", color: "A8DADC" },
|
||||
{ x: 1, y: 3.8, label: "公司 C\n廣告素材", color: "A8DADC" },
|
||||
{ x: 7.5, y: 3.8, label: "公司 D\n廣告素材", color: "A8DADC" }
|
||||
];
|
||||
isolations.forEach(i => {
|
||||
slide5.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: i.x, y: i.y, w: 1.5, h: 0.8, fill: { color: COLORS.cardBg }, line: { color: COLORS.secondary, width: 1 }, rectRadius: 0.1 });
|
||||
slide5.addText(i.label, { x: i.x, y: i.y, w: 1.5, h: 0.8, fontSize: 12, fontFace: FONTS.body, color: COLORS.text, align: "center", valign: "middle" });
|
||||
slide5.addShape(pres.shapes.LINE, { x: i.x + (i.x < 5 ? 1.5 : 0), y: i.y + 0.4, w: i.x < 5 ? (4.25 - (i.x + 1.5)) : (7.5 - (i.x + 1.5)), h: 2.75 - (i.y + 0.4), line: { color: COLORS.secondary, width: 1, dashType: "dash" } });
|
||||
});
|
||||
slide5.addText("嚴格驗證 company_id,確保素材 100% 歸屬隔離。", { x: 0.5, y: 5.0, w: 9, h: 0.4, fontSize: 14, fontFace: FONTS.body, color: COLORS.muted, align: "center", italic: true });
|
||||
|
||||
// --- Slide 6: Future Roadmap ---
|
||||
const slide6 = pres.addSlide();
|
||||
slide6.background = { color: COLORS.bg };
|
||||
slide6.addText("未來計畫 / Roadmap", { x: 0.5, y: 0.4, w: 9, h: 1, fontSize: 36, fontFace: FONTS.header, color: COLORS.highlight, bold: true, align: "center" });
|
||||
const roadmap = [
|
||||
{ icon: "Optim", title: "操作優化", text: "針對樂樂反饋優化操作流程與介面體驗。" },
|
||||
{ icon: "Sync", title: "API 對接", text: "與 Terry 配合,實現機台通訊穩定與對接。" },
|
||||
{ icon: "Remote", title: "遠端管理", text: "實作遠端控制與異常即時監控監管。" }
|
||||
];
|
||||
roadmap.forEach((r, idx) => {
|
||||
const x = 0.5 + (idx * 3.1);
|
||||
slide6.addShape(pres.shapes.RECTANGLE, { x: x, y: 2.0, w: 2.8, h: 2.5, fill: { color: COLORS.highlight, transparency: 85 }, line: { color: COLORS.highlight, width: 2 }, shadow: makeShadow() });
|
||||
slide6.addText(r.title, { x: x, y: 2.3, w: 2.8, h: 0.5, fontSize: 20, fontFace: FONTS.header, color: COLORS.highlight, bold: true, align: "center" });
|
||||
slide6.addText(r.text, { x: x + 0.2, y: 3.0, w: 2.4, h: 1.2, fontSize: 14, fontFace: FONTS.body, color: COLORS.text, align: "center" });
|
||||
});
|
||||
slide6.addText("Star Cloud 將持續進化,打造最領先的智能雲端平台。🚀", { x: 0.5, y: 5.0, w: 9, h: 0.4, fontSize: 14, fontFace: FONTS.body, color: COLORS.secondary, align: "center", bold: true });
|
||||
|
||||
pres.writeFile({ fileName: "star-cloud-demo-20260331.pptx" })
|
||||
.then(fileName => console.log(`Presentation created: ${fileName}`))
|
||||
.catch(err => { console.error("Error creating presentation:", err); process.exit(1); });
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user