Compare commits

...

62 Commits

Author SHA1 Message Date
f49938d1a7 [DOCS] 補齊 B024, B027, B055 API 技術規格與文檔定義
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m1s
1. 更新技術手冊 (SKILL.md) 與 API 規格文件 (api-docs.php),補齊 B024, B027, B055 之定義。
2. 修正文件序號跳號問題,確保 B017 序號正確銜接。
3. 詳細說明 B055 之指令 ID 遠端出貨運作機制。
2026-04-14 17:36:56 +08:00
2702e5a655 [REFACTOR] 標準化 IoT API 方法並修正日期格式
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 57s
1. 將 B014 (參數下載) 與 B017 (貨道同步) 路由從 POST 改為 GET。
2. 移除 B017 冗餘的 machine 參數,改由 Bearer Token 自動識別。
3. 修正 B017 回傳資料中 expiry_date 的 ISO 8601 格式問題,統一為 Y-m-d。
4. 同步更新所有技術規格文件 (.agents/ 規範) 與 API 說明配置 (config/api-docs.php)。
2026-04-14 16:46:35 +08:00
6382709b90 [DOCS] 更新開發進度表
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 55s
1. 標記「遠端管理」模組為已完成。
2. 標記「儀表板」模組為已完成。
2026-04-14 16:18:05 +08:00
66f7c1ffb8 [FEAT] 實作 B017 貨道庫存全量同步 API 與多語系支援
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 56s
1. 實作 B017 (reload_msg) 端點,支援貨道庫存、效期與批號的全量同步。
2. 將 B017 回傳欄位映射至 App 需求:slot_no -> tid, stock -> num。
3. 新增 expiry_date (效期) 與 batch_no (批號) 欄位支援。
4. 實作指令狀態閉環邏輯,成功同步後自動將相關 reload_stock 指令標記為成功。
5. 將指令備註欄位多語系化,新增「庫存已與機台同步」繁中、英文、日文翻譯。
6. 更新 API 技術規格文件 (.agents/skills) 與系統 API 文件配置 (config/api-docs.php)。
2026-04-14 15:06:51 +08:00
32fa28dc0f [DOCS]:初始化 MQTT 架構實作計畫與相關技術規範
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m12s
1. 新增 MQTT 即時通訊與 Topic 規範文件 (.agents/skills/mqtt-communication-specs/SKILL.md)。
2. 建立 MQTT 基礎架構實作計畫文件 (docs/mqtt-implementation-plan.md)。
3. 更新全域開發框架規範 (framework.md),納入 Go Gateway 與 EMQX 架構說明。
4. 重構 IoT 通訊處理規範 (iot-communication/SKILL.md),支援 HTTP 與 MQTT 雙軌管線。
5. 更新背景 API 規範 (api-rules.md) 與技能觸發規則 (skill-trigger.md) 以符合新架構。
2026-04-14 13:02:08 +08:00
daf8b1ebcc [FEAT] 強化機台技術員 Token 安全性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 46s
1. 為 B000 登入發放之技術員 Token 增加 8 小時有效期限,防止 Token 永久有效之風險。
2026-04-13 17:24:57 +08:00
8f008ffb61 [FEAT] 實作 B014 機台參數下載 API 與 B000 登入認證強化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 52s
1. 強化 B000 登入接口:驗證成功後回傳 Sanctum Token 供後續初始化使用。
2. 實作 B014 (getSettings) API:整合機台、金流與發票設定,並映射至 Android App 預期欄位。
3. 強化安全性:B014 API 掛載 auth:sanctum 並執行 RBAC 權限檢查。
4. 更新 API 說明文件 (iot-spec.md, api-docs.php) 及技術規範 (SKILL.md)。
2026-04-13 17:04:52 +08:00
729890d7c7 [DOCS] 更新通訊系統與全域工具列開發藍圖
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 47s
1. 更新 docs/future_todo.md:詳列第一階段核心工具(工具列、公告系統、快捷入口、身分模擬)與第二階段行銷功能(盲盒抽獎)的開發時程。
2026-04-13 16:19:37 +08:00
ad256d3d3b [FEAT] 廣告排程功能與 UI 優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 48s
1. 新增廣告排程功能,支援設定發布時間與下架時間。
2. 整合 Flatpickr 時間選擇器,提供與機台日誌一致的極簡奢華風 UI。
3. 優化廣告列表中的數字字體,套用 font-mono 與 tabular-nums,與客戶管理模組風格同步。
4. 修正 Alpine.js 資料同步邏輯,確保編輯模式下排程時間能正確回填。
2026-04-13 11:44:26 +08:00
5415b14a53 [DOCS] 更新 .gitignore 以排除 pptx 目錄
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m10s
1. 在 .gitignore 中新增 /docs/pptx 路徑,避免產出的簡報檔案意外進入版本控制。
2026-04-13 11:01:21 +08:00
c97776892e [FEAT] 優化客戶合約管理介面與修復日期偏移問題
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 52s
1. 整合「客戶詳情」與「合約歷程」至單一側邊欄,改用分頁 (Tabs) 切換介面。
2. 優化清單「服務期程」顯示:根據客戶模式(租賃/買斷)動態顯示對應期間,並使用完整文字標籤取代縮寫。
3. 修復日期 Bug:在 Company 模型指定日期序列化格式為 Y-m-d,解決時區轉換導致的日期減少一天問題。
4. 新增合約歷程資料表模型、遷移檔以及對應的多語系翻譯(中、英、日)。
5. 移除清單操作列中重複的合約歷程圖示。
2026-04-08 17:41:26 +08:00
a599b14df1 [FEAT] 優化機台硬體通訊協議與管理介面互動性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m6s
1. 修復帳號管理與角色權限頁面搜尋功能,支援 Enter 鍵快捷提交。
2. 完成 B013 (機台故障上報) API 實作,改用非同步隊列 (ProcessMachineError) 處理日誌上報。
3. 精簡 B013 API 參數,移除冗餘的 message 欄位,統一由雲端對照表翻譯。
4. 更新技術規格文件 (SKILL.md) 與系統 API 文件配置 (api-docs.php)。
5. 修正平台管理員帳號在搜尋過濾時的資料隔離邏輯。
2026-04-08 14:52:00 +08:00
c343df34ee [FEAT] 實作 B012 商品同步 API 與統一圖片絕對網址格式
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 58s
1. 實作 B012 API:新增 /api/v1/app/machine/products/B012 端點,支援 GET (全量) 與 PATCH (增量) 同步邏輯。
2. 統一圖片 URL:在 B005 與 B012 API 中使用 asset() 確保回傳絕對網址 (Absolute URL),解決 App 端下載相對路徑的問題。
3. 文件更新:同步更新 SKILL.md 的欄位定義,並在 api-docs.php 補上 B012 的正式規格說明。
4. 資料庫變更:新增 machine_slots 表的 type 欄位與相關註解遷移。
5. 格式優化:為技術規格文件中的 API 欄位與狀態碼加上反引號,提升文件中心可讀性。
2026-04-07 17:05:28 +08:00
253ae8afd4 [FEAT] 實作機台序號編輯功能與多語系支援
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 49s
1. 更新機台控制器 (MachineController),在更新時執行 serial_no 的必填與唯一性驗證。
2. 修改機台管理首頁 (index.blade.php),在快速編輯彈窗中加入機台序號輸入欄位。
3. 修正基礎設定中機台編輯頁面 (edit.blade.php),將原本唯讀的機台序號欄位改為可編輯輸入框,並加入必填標記。
4. 補齊並統一繁體中文、英文、日文翻譯檔中關於「機台序號」的翻譯 Key。
2026-04-07 14:55:24 +08:00
f2147ae6c4 [FEAT] 完善 IoT API 規范化、機台管理介面優化與 B005 改為 GET
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m4s
1. 將 B005 (廣告同步) 從 POST 改為 GET,符合 RESTful 規範。
2. 完善 B009 (庫存回報) 回應規格,加入業務代碼 (200 OK)。
3. API 文件 UI 優化:新增 Method Badge (方法標籤),並修正 JSON 中文/斜線轉義問題。
4. 機台管理介面優化:實作「唯讀庫存與效期」面板,並將日誌圖示改為「👁️」。
5. 標準化 ID 識別邏輯:資料表全面移除對 sku 的依賴,改以 id 為主、barcode 為輔。
6. 新增 Migration:正式移除 sku 欄位並同步 barcode 指向。
7. 更新多語系支援 (zh_TW, en, ja)。
2026-04-07 14:37:57 +08:00
b60afc3abe [FIX] 修正與標準化 B005 廣告下載 API 以相容既有 Android App
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m7s
1. 修改 MachineController@getAdvertisements 回傳欄位,將 t070v02 改為播放秒數,t070v03 改為位置代碼 (Flag)。
2. 根據 Android App 原始碼分析結果,調整位置對應:3 為待機廣告 (HomeActivity),1 為販賣頁廣告 (FontendActivity)。
3. 在 AdvertisementController@getMachineAds 增加 sort_order 排序,確保後台管理介面視圖與 API 輸出順序一致。
4. 更新廣告下載 API 的技術文件 (SKILL.md),明確標記欄位用途與位置代碼。
5. 在 routes/api.php 補上 B005 與 B009 的路由定義。
2026-04-07 11:41:24 +08:00
bbdc5bad9f [FEAT] 重構機台狀態判定邏輯並優化全站多語系支援
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m18s
1. 重構機台在線狀態判定機制:移除資料庫 status 欄位,改由 Model 根據心跳時間動態計算。
2. 修正儀表板 (Dashboard) 與機台管理頁面的多語系顯示問題,解決換行導致翻譯失效的 Bug。
3. 修正個人檔案頁面的麵包屑 (Breadcrumbs) 導航,補齊「個人設定」層級。
4. 更新 IoT API (B010, B600) 的認證機制與日誌處理邏輯。
5. 同步更新繁中、英文、日文語言檔,確保 UI 標籤一致性。
2026-04-07 10:21:07 +08:00
08b6c60d2e [FEAT] 實作 B000 機台登入 API 並同步 API 規格文件
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 45s
1. 於 routes/api.php 新增 B000 登入驗證路由,並設定速率限制。
2. 更新 .agents/skills/api-technical-specs/SKILL.md,補上 B000 規格並根據 Java App 實作精簡 B010 指令碼。
3. 同步更新 config/api-docs.php 中的 API 說明文件。
4. 調整 RemoteController 指令歷史紀錄上限為 5 筆以優化效能。
5. 統一機台編輯頁面之圖片上傳文字說明與標題。
6. 補齊多語系翻譯檔案 (zh_TW, en, ja) 出現的新字串。
2026-04-02 17:24:13 +08:00
e085058d63 [FEAT] 遠端指令中心優化與規格同步
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m11s
1. 實作遠端指令去重機制 (Supersede):避免重複下達相同待執行指令。
2. 修正遠端指令發送後的 Toast 提示邏輯,確保頁面跳轉後正確顯示回饋。
3. 增加 RemoteCommand 操作者 (user_id) 紀錄與狀態列舉擴充 (superseded)。
4. 修復機台列表「最後頁面」欄位對照錯誤,同步更新 Machine Model 與 API 規格。
5. 優化遠端指令中心 UI:放大卡片字體、調整側面欄間距,符合極簡奢華風規範。
6. 更新 API 技術規格書 (SKILL.md) 與 config/api-docs.php,補全所有機台代碼 (66-611) 與指令。
7. 補全繁體中文、英文、日文多語系翻譯檔案。
2026-04-02 14:57:41 +08:00
e7ad7e3dc3 [FEAT] 整合遠端管理指揮中心與 UI 佈局優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 48s
1. 實作「遠端管理指揮中心」,整合重啟、結帳、鎖定、找零、出貨等指令至單一介面。
2. 對接 B010 心跳 API 與 B017 庫存 API,實作異步指令下發與效期/批號同步邏輯。
3. 修正 sidebar-menu.blade.php 中的舊版路由連結,解決 RouteNotFoundException 錯誤。
4. 修正 index.blade.php 中的 AJAX 請求名稱,補上 admin. 前綴以符合路由分群。
5. 優化主內容區頂部間距,將 pt-10 縮減為 pt-5,提昇介面緊湊度。
2026-04-01 16:59:29 +08:00
3dbb394862 [REFACTOR] 移除機台管理與庫存設定中的貨道同步套用功能與日誌面板冗餘分頁
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 46s
1. 移除 MachineController 貨道更新 API 的 apply_all_same_product 驗證規則。
2. 簡化 MachineService 的 updateSlot 邏輯,取消「同步套用至同機台其他相同商品」的批次異動功能以確保資料準確性。
3. 清理 index.blade.php 機台管理頁面中的「貨道狀態 (Slot Status)」分頁、相關 Alpine.js 函式與專用的貨道編輯 Modal。
4. 修正 stock.blade.php 庫存管理介面,移除編輯 Modal 內的同步切換開關。
2026-04-01 16:10:40 +08:00
969e4df629 [STYLE] 修復機台庫存管理功能並全面升級極簡奢華風 UI
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 56s
1. [FIX] 修復 MachineController 500 錯誤:注入缺失的 MachineService 執行個體。
2. [STYLE] 貨道卡片重構:改為垂直堆疊佈局,移除冗餘標籤,並優化庫存 (x/y) 與效期格式。
3. [STYLE] 極致化間距調優:壓縮全域 Padding 與 Gap,並將貨道編號絕對定位於頂部,提升顯示密度。
4. [FIX] 穩定性修復:解決 Alpine.js 在返回列表時的 selectedMachine 空值存取報錯。
5. [STYLE] UI 細節修飾:隱藏輸入框微調箭頭,強化編號字體粗細與位置精準度。
6. [DOCS] 翻譯同步:更新 zh_TW, en, ja 翻譯檔中關於庫存與貨道的語系 Key。
7. [FEAT] 整合遠端管理模組:新增並導航至 resources/views/admin/remote/stock.blade.php。
2026-04-01 15:26:21 +08:00
7c47ad67fa [FIX] 解決與 demo 分支的合併衝突
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m9s
2026-04-01 13:03:08 +08:00
953d6a41f3 [FEAT] 優化廣告與機台管理介面及效能
1. [廣告管理] 修復編輯素材時刪除按鈕顯示邏輯並優化預覽功能。
2. [廣告管理] 修正請求回傳格式為 JSON,解決 AJAX 解析錯誤。
3. [機台管理] 實作 Alpine.js 無感頁籤切換(機台列表與效期管理)。
4. [機台管理] 移除冗餘返回按鈕,改為動態標題與頁籤重設邏輯。
5. [機台管理] 統一後端查詢,減少切換分頁時的延遲感。
6. [商品管理] 支援商品圖片 WebP 自動轉換,並調整上傳大小限制 (10MB)。
7. [UI] 修正多個管理模組的 JS 時序競爭與 Preline HSSelect 重置問題。
2026-04-01 13:01:45 +08:00
2e49129d77 [FEAT] 重構子帳號角色管理至子帳號頁籤,並全面標準化商品與機台模組 UI 樣式
Detail:
- 將「子帳號角色」從全域資料設定移入子帳號管理頁籤項下
- 移除冗餘的子帳號角色獨立權限,整合入子帳號管理權限
- 標準化商品列表與分類列表的圖示容器、懸停變色與奢華風陰影
- 修正分類名稱 (Category Name) 在 zh_TW 中的多語系缺漏
- 同步優化機台列表與權限管理介面的圖示交互效果
2026-04-01 09:53:30 +08:00
08fc86d3f8 [STYLE] 商品管理與分類管理 UI 標準化,補全多語系翻譯
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 50s
2026-04-01 09:50:57 +08:00
e27eee78f5 [STYLE] 標準化商品管理與廣告彈窗 UI,完善商品分類多語系 CRUD 功能
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m2s
2026-04-01 08:38:03 +08:00
759fae4380 [FEAT]:新增 Demo Day PPTX 自動生成腳本與相關套件 2026-03-31 16:26:13 +08:00
54d62c5378 [FEAT] 實作機台廣告管理模組與多語系支援
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m7s
1. 新增廣告管理列表與機台配置介面,包含多語系 (zh_TW, en, ja) 與完整 CRUD
2. 實作基於 Alpine 的廣告素材預覽輪播功能
3. 優化廣告素材下拉選單,強制綁定所屬公司以達成多租戶資料隔離
4. 重構廣告配置中廣告影片的縮圖渲染邏輯,移除 <video> 標籤以大幅提升頁面載入速度與節省頻寬
5. 放寬個人檔案頭像上傳限制,支援 WebP 格式
2026-03-31 13:30:41 +08:00
d14eda7d69 [FIX] 修復商品多語系儲存與讀取錯誤、新增自動語系名稱顯示、補強商品規格欄位及密碼顯示切換功能
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 47s
2026-03-30 17:11:15 +08:00
9bbfaa39e6 [FEAT] 整合機台設定之機台權限管理功能,優化篩選器佈局並修復 Alpine.js 語法錯誤
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 50s
2026-03-30 16:37:18 +08:00
2c9dc793d7 [FEAT] 機台權限功能增強、UI 輕巧化與多語系優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 46s
2026-03-30 16:15:04 +08:00
f3b2c3e018 [FIX] 遷移機台授權為獨立模組:修復變數命名、補齊多語系並強化多租戶數據隔離
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 54s
2026-03-30 15:30:46 +08:00
44ef355c54 [FEAT] 完善機台授權模組:新增搜尋過濾功能、機台資訊排版優化、更換圖格裝飾並完成後端效能優化。
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 49s
2026-03-30 13:48:22 +08:00
ea0333d77e [STYLE] 系統用語標準化與客戶管理 UI 點選透明度優化 (所屬單位 -> 公司名稱、姓名 -> 名稱)
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 59s
2026-03-30 10:16:05 +08:00
e780e195e2 style(UI): 微調放大客戶管理中業務類型的字體大小
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 56s
2026-03-30 09:14:52 +08:00
fdd3589d7b feat(Admin/Company): 擴充業務類型與合約期間功能,補齊多語系翻譯詞條
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m9s
2026-03-30 09:08:02 +08:00
c875ab7d29 [FEAT] 移除「商品狀態」冗餘模組、優化麵包屑導航與完善帳號角色過濾邏輯
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 46s
2026-03-27 16:53:43 +08:00
740eaa30b7 [FEAT] 優化後端帳號權限邏輯、開發商品管理功能及聯絡資訊 UI 改版
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m2s
2026-03-27 13:43:08 +08:00
8ec5473ec7 [FEAT] 商品管理模組重構、UI 清晰度優化與多語系標籤字體調整 2026-03-26 17:32:15 +08:00
ac51027dda [DOCS] 建立獨立 API 技術規格技能 (api-technical-specs) 並精簡 IoT 通訊規範
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 47s
2026-03-26 15:03:00 +08:00
17b5c1a316 [DOCS] 現代化 IoT API (B010) 通訊協議與文件中心 UI/UX 重整 2026-03-26 14:59:49 +08:00
7883a755d2 [STYLE] 統一機台所屬單位用語,並實裝「系統」預設值邏輯 🦾⚙️
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 42s
2026-03-26 13:21:25 +08:00
03f8fdb654 [STYLE] 調整機台新增表單:將公司設為選填並強化型號必填標示 🦾⚙️
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m2s
2026-03-26 13:16:55 +08:00
f60e5a9c72 [FEAT] 優化機台 API 通訊識別、補齊前端必填驗證、並配置 Demo 站隊列自動化部署 🦾🚀
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 49s
2026-03-26 13:09:48 +08:00
19076c363c [STYLE] 更新系統 Logo 為無損圓形去背版並補齊登入頁繁體中文翻譯
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m4s
2026-03-26 09:22:07 +08:00
675e285e8c [STYLE] 補齊帳號管理按鈕 Tooltip 翻譯語系
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 42s
2026-03-25 17:19:23 +08:00
b7ff8ac01c [FEAT] 完善帳號管理狀態切換功能、優化多語系提示與 UI 樣式一致性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 42s
2026-03-25 17:16:41 +08:00
c015666f87 [FIX] Machine Utilization dark mode visibility and contrast
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 42s
2026-03-25 14:50:56 +08:00
3629caebd0 [FEAT] Machine Utilization UI refinement & localization 2026-03-25 14:47:52 +08:00
37ef6f1c10 [FEAT] 實作維修管理模組與 RBAC 權限整合、多語系支援及 UI 優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m3s
2026-03-25 14:25:42 +08:00
3d24ddff5a [FIX] 修正新增角色時系統權限與單位顯示消失之問題
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 49s
2026-03-25 09:56:03 +08:00
9f3a90b2b0 [FIX] 修復帳號管理角色下拉選單消失問題並優化初始化防護 & [STYLE] 新增個人檔案選單圖標
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 58s
2026-03-25 09:47:17 +08:00
2467d9db7a [UI] 為機台管理子選單添加圖示
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 42s
2026-03-24 17:00:38 +08:00
f98d059bc3 [FIX] 重新導向機台列表麵包屑至正確層級
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 50s
2026-03-24 16:57:15 +08:00
7209f9ea98 [FIX] 還原麵包屑預設邏輯,移除索引頁面重複顯示
Some checks failed
star-cloud-deploy-demo / deploy-demo (push) Has been cancelled
2026-03-24 16:55:57 +08:00
5c55553905 [FIX] 移除麵包屑冗餘顯示項目
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 46s
2026-03-24 16:53:51 +08:00
87ef247a48 [FIX] 整合機台效期管理功能並優化 UI 比例
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m2s
- 修正 Alpine.js 作用域問題,恢復效期編輯彈窗功能
- 整合機台日誌與效期管理至主列表頁 (Index)
- 優化大螢幕貨道格線佈局,解決日期折行問題
- 縮小彈窗字體與內距,調整為極簡奢華風 UI
- 新增貨道效期與批號欄位之 Migration 與模型關聯
- 補齊中、英、日三語系翻譯檔
2026-03-24 16:46:04 +08:00
38770b080b [FEAT] 優化帳號管理授權顯示邏輯與 UI 樣式一致性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 59s
2026-03-23 17:16:26 +08:00
72812f9b0b [FEAT] 角色權限編輯頁面重構與多項 UI/翻譯優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 44s
- 新增獨立角色權限編輯頁面 (roles-edit.blade.php),採整合式佈局
- 重構 PermissionController 以支援角色建立/編輯/刪除完整 CRUD
- 移除角色手動層級選擇,改為自動判定並顯示所屬單位
- 補齊 20+ 項 menu 權限 Key 的三語系翻譯 (zh_TW/en/ja)
- 修正子項目佈局跑版問題 (min-w-0/flex-shrink-0 防溢出)
- 更新 RoleSeeder 加入巢狀權限結構
- 同步更新側邊欄選單與路由配置
2026-03-20 17:35:06 +08:00
d2cefe3f39 [FEAT] 完善全站多語系支援、角色權限篩選優化及 UI 元件重構
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m4s
- [DOCS] 補齊 en, ja, zh_TW 語系檔翻譯並完善驗證錯誤訊息 (validation.php)
- [FEAT] 角色權限頁面新增「所屬單位」篩選功能 (僅限系統管理員)
- [STYLE] 優化角色列表顯示,將「類型」變更為具體「所屬單位」名稱
- [STYLE] 修正角色頁面工具列佈局,搜尋框置前並修正下拉箭頭顯示
- [REFACTOR] 統一全站刪除確認視窗,導入新版 <x-delete-confirm-modal /> 組件
- [REFACTOR] 優化 PermissionController 查詢效能 (Eager Loading)
- [FIX] 修正 RoleSeeder 角色命名與資料庫同步邏輯
2026-03-20 13:41:51 +08:00
6588dcd7f7 [REFACTOR] 將帳號管理之 Email 欄位改為選填
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 54s
2026-03-19 17:22:12 +08:00
159 changed files with 21901 additions and 3324 deletions

View File

@@ -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 在背景完成數據持久化。
---

View File

@@ -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)

View File

@@ -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)」。

View File

@@ -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` 隔離邏輯正確套用

View 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" |

View File

@@ -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` 識別並自動關聯資料。

View File

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

View File

@@ -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-*` 系列組件是否滿足需求。**

View File

@@ -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
View File

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

View 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.')
]);
}
}

View File

@@ -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;
}
}

View File

@@ -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
]);
}
}

View File

@@ -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'));
}

View File

@@ -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.'));

View File

@@ -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'

View File

@@ -25,15 +25,6 @@ class DataConfigController extends Controller
]);
}
// 管理者可賣商品
public function adminProducts()
{
return view('admin.placeholder', [
'title' => '商品狀態',
'description' => '管理者商品銷售權限',
]);
}
// 子帳號管理
public function subAccounts()

View File

@@ -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()
]);
}
}

View File

@@ -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
]);
}
/**

View 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'));
}
}

View File

@@ -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]));
}
}

View 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());
}
}
}

View 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());
}
}
}

View 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');
}
}

View File

@@ -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')
]);
}
}

View 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
]);
}
}

View File

@@ -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 預期的是包含單一物件的陣列
]);
}
}

View File

@@ -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);

View 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'));
}
}

View File

@@ -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.'));
}
}

View File

@@ -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();

View File

@@ -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.'));
}

View File

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

View File

@@ -0,0 +1,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);
}
}
}

View File

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

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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);

View File

@@ -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()

View 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);
}
}

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View File

@@ -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 ?? '';
}
}

View 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);
});
}
}

View File

@@ -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.
*/

View File

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

View File

@@ -13,6 +13,10 @@ class Role extends SpatieRole
'is_system',
];
protected $casts = [
'is_system' => 'boolean',
];
/**
* Get the company that owns the role.
*/

View File

@@ -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',

View File

@@ -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.
*/

View File

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

View 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;
}
}

View File

@@ -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,
];
/**

View File

@@ -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();
}
}

View File

@@ -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'],

View 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;
}
}

View File

@@ -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

View File

@@ -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
View File

@@ -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
View 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']
]
],
]
]
]
]
];

View File

@@ -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'),
];
}

View 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,
],
];
}
}

View File

@@ -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');
}
};

View File

@@ -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']);
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('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']);
});
}
};

View File

@@ -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');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('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');
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('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();
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('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');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('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');
});
}
};

View File

@@ -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']);
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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
{
// 基本上這是資料修正,回復也不太有意義
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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 或手動新增。
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('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');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('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');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
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'");
}
}
};

View File

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

View File

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

View File

@@ -0,0 +1,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');
});
}
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

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

View File

@@ -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
View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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 秒後再試。',
];

View File

@@ -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
View File

@@ -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",

View File

@@ -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
View 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