Compare commits

..

57 Commits

Author SHA1 Message Date
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
154 changed files with 21176 additions and 3279 deletions

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

@@ -15,7 +15,8 @@ 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` |
| 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,260 @@
---
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**: POST /api/v1/app/machine/setting/B014
- **Authentication**: **User Token** (Sanctum Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
- **Response Body (Success 200):**
| 欄位 (Key) | 說明 | 備註 |
| :--- | :--- | :--- |
| **t050v01** | 機台序號 | 即 machine_id |
| **api_token** | **機台正式 Token** | 初始化後應存於本地,後續 API 認證用 |
| **t050v41** | 玉山特店編號 | ESUN Merchant ID |
| **t050v42** | 玉山終端編號 | ESUN Terminal ID |
| **t050v43** | 玉山 Hash Key | ESUN Hash |
| **t050v34** | 發票特店 ID | Invoice Merchant ID |
| **t050v35** | 發票 Hash Key | Invoice Key |
| **t050v36** | 發票 Hash IV | Invoice IV |
| **TP_APP_ID** | 趨勢支付 AppID | TrendPay ID |
| **TP_APP_KEY** | 趨勢支付 Key | TrendPay Key |
> [!CAUTION]
> **安全性規範**B014 會回傳敏感金鑰與正式 Token背景必須強制進行 RBAC 校驗。只有當前登入的人員具備該機台管理權限時,後端才允許發放資料。

View File

@@ -60,4 +60,19 @@ public function handle(MachineService $service): void
- [ ] 是否使用了 `ApiResponse` Trait
- [ ] 業務邏輯是否已封裝至 `App\Services`
- [ ] 是否使用了 Redis Queue 進行非同步處理?
- [ ] 是否在 API 層級進行了基礎的參數驗證?
## 6. API 規格定義 (API Specifications)
> [!IMPORTANT]
> **規格分離原則**:本技能僅規範「通訊處理邏輯」。關於具體的 API 欄位、參數命名、狀態代碼對照與範例,請務必參閱專屬技能規範:
> - **[API 技術規格與通訊協議規範](file:///home/mama/projects/star-cloud/.agents/skills/api-technical-specs/SKILL.md)**
### 常見端點處理模式
1. **B010 (心跳)**:高頻點,必須進入 Redis Queue。更新 `last_heartbeat_at` 與感測器快照。
2. **B600 (交易)**:高價值點,必須進入任務隊列並支援重試。建立 `Transaction` 紀錄。
3. **B017 (貨道)**:回覆較大資料量,應確保 Service 層具備緩存 (Cache) 機制。
---
> [!CAUTION]
> **身份識別機制**:禁止在 Body 傳輸 `machine``key`。系統強制透過 `Bearer Token` 識別並自動關聯資料。

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,19 +23,148 @@ 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)
*/
@@ -39,6 +173,12 @@ class MachineController extends Controller
$machine = $request->get('machine');
$slots = $machine->slots()->with('product')->get();
// 自動轉 Success: 若機台來撈 B017代表之前的 reload_stock 指令已成功被機台響應
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'reload_stock')
->where('status', 'sent')
->update(['status' => 'success', 'executed_at' => now()]);
return response()->json([
'success' => true,
'code' => 200,
@@ -50,6 +190,8 @@ class MachineController extends Controller
'capacity' => $slot->capacity,
'price' => $slot->price,
'status' => $slot->status,
'expiry_date' => $slot->expiry_date,
'batch_no' => $slot->batch_no,
];
})
]);
@@ -61,7 +203,7 @@ class MachineController extends Controller
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 +216,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 +262,280 @@ class MachineController extends Controller
]
]);
}
/**
* B005: Download Machine Advertisements (Synchronous)
*/
public function getAdvertisements(Request $request)
{
$machine = $request->get('machine');
$advertisements = \App\Models\Machine\MachineAdvertisement::where('machine_id', $machine->id)
->with([
'advertisement' => function ($query) {
$query->playing();
}
])
->get()
->filter(fn($ma) => $ma->advertisement !== null)
->map(function ($ma) {
// 定義顯示順序權重 (待機 > 購物 > 成功禮)
$posWeight = [
'standby' => 1,
'vending' => 2,
'visit_gift' => 3
];
// 為了相容現有機台 App 邏輯:
// App 讀取 t070v03 作為位置標籤 (flag)
// 1. HomeActivity (待機) 讀取 "3"
// 2. FontendActivity (販賣頁) 讀取 "1"
$posIdMap = [
'standby' => '3',
'vending' => '1',
'visit_gift' => '2'
];
return [
't070v01' => $ma->advertisement->name,
't070v02' => (string) ($ma->advertisement->duration ?? 15), // 秒數改放這裡
't070v03' => (string) ($posIdMap[$ma->position] ?? '1'), // 位置數字改放這裡 (App 會讀這欄當 Flag)
't070v04' => $ma->advertisement->url ? (str_starts_with($ma->advertisement->url, 'http') ? $ma->advertisement->url : asset($ma->advertisement->url)) : '',
't070v05' => (string) $ma->sort_order,
'raw_pos_weight' => $posWeight[$ma->position] ?? 99,
'raw_sort' => (int) $ma->sort_order
];
})
->sortBy([
['raw_pos_weight', 'asc'],
['raw_sort', 'asc']
])
->values()
->map(function ($item) {
unset($item['raw_pos_weight'], $item['raw_sort']);
return $item;
});
return response()->json([
'success' => true,
'code' => 200,
'data' => $advertisements->values()
]);
}
/**
* B009: Report Machine Slot List / Supplementary (Synchronous)
*/
public function reportSlotList(Request $request, \App\Services\Machine\MachineService $machineService)
{
$machine = $request->get('machine');
$payload = $request->all();
$account = $payload['account'] ?? null;
// 1. 驗證帳號是否存在 (驗證執行補貨的人員身分)
$user = User::where('username', $account)
->orWhere('email', $account)
->first();
if (!$user) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Unauthorized: Account not found',
'status' => ''
], 403);
}
// 2. 階層式權限驗證 (遵循 RBAC 多租戶規範)
$isAuthorized = false;
if ($user->isSystemAdmin()) {
// [系統層]:系統管理員可異動所有機台
$isAuthorized = true;
} elseif ($user->is_admin) {
// [公司層]:公司管理員需驗證此機台是否隸屬於該公司
$isAuthorized = ($machine->company_id === $user->company_id);
} else {
// [人員層]:一般人員需檢查 machine_user 授權表
$isAuthorized = $user->machines()->where('machine_id', $machine->id)->exists();
}
if (!$isAuthorized) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Unauthorized: Account not authorized for this machine',
'status' => ''
], 403);
}
// 3. 映射舊版機台回傳格式 (Map legacy machine format)
// 支持單個物件 data: {} 或 陣列 data: [{}] (Handle both single object and array)
$legacyData = $payload['data'] ?? [];
if (Arr::isAssoc($legacyData)) {
$legacyData = [$legacyData];
}
$mappedSlots = array_map(function ($item) {
return [
'slot_no' => $item['tid'] ?? null,
'product_id' => $item['t060v00'] ?? null,
'stock' => $item['num'] ?? 0,
'type' => $item['type'] ?? null,
];
}, $legacyData);
// 過濾無效資料 (Filter invalid entries)
$mappedSlots = array_filter($mappedSlots, fn($s) => $s['slot_no'] !== null);
// 同步處理更新庫存 (直接更新不進隊列)
$machineService->syncSlots($machine, $mappedSlots);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Slot report synchronized success',
'status' => '49'
]);
}
/**
* B012_new: Download Product Catalog (Synchronous)
*/
public function getProducts(Request $request)
{
$machine = $request->get('machine');
$products = \App\Models\Product\Product::where('company_id', $machine->company_id)
->with(['translations'])
->active()
->get()
->map(function ($product) {
// 提取多語系名稱 (Extract translations)
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
return [
't060v00' => (string) $product->id, // ID 仍建議維持字串,增加未來編號彈性
't060v01' => $product->name,
't060v01_en' => $nameEn,
't060v01_jp' => $nameJp,
't060v03' => $product->spec ?? '',
't060v06' => $product->image_url ? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url)) : '',
't060v09' => (float) $product->price,
't060v11' => (int) ($product->track_limit ?? 10),
't060v30' => (float) ($product->member_price ?? $product->price),
't060v40' => $product->metadata['marketing_plan'] ?? '', // 行銷計畫
't060v41' => $product->metadata['material_code'] ?? $product->barcode ?? '', // 物料編碼 (優先從 metadata 找,回退至條碼)
'spring_limit' => (int) ($product->spring_limit ?? 10),
'track_limit' => (int) ($product->track_limit ?? 10),
't063v03' => (float) $product->price,
];
});
return response()->json([
'success' => true,
'code' => 200,
'data' => $products
]);
}
/**
* B013: Report Machine Hardware Error/Status (Asynchronous)
*/
public function reportError(Request $request)
{
$machine = $request->get('machine');
$data = $request->only(['tid', 'error_code']);
// 異步分派處理 (Dispatch to queue)
ProcessMachineError::dispatch($machine->serial_no, $data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Error report accepted',
], 202); // 202 Accepted
}
/**
* B014: Download Machine Settings & Config (Synchronous, Requires User Auth)
* 用於機台引導階段,同步金流、發票與機台專屬 API Token。
*/
public function getSettings(Request $request)
{
$serialNo = $request->input('machine');
$user = $request->user();
// 1. 查找機台 (忽略全局範圍以進行認領)
$machine = Machine::withoutGlobalScopes()
->with(['paymentConfig', 'company'])
->where('serial_no', $serialNo)
->first();
if (!$machine) {
return response()->json([
'success' => false,
'code' => 404,
'message' => 'Machine not found'
], 404);
}
// 2. 權限加強驗證 (RBAC)
$isAuthorized = false;
if ($user->isSystemAdmin()) {
$isAuthorized = true;
} elseif ($machine->company_id === $user->company_id) {
// 公司管理員或已授權員工才能存取
if ($user->is_admin || $user->machines()->where('machine_id', $machine->id)->exists()) {
$isAuthorized = true;
}
}
if (!$isAuthorized) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Forbidden: You do not have permission to configure this machine'
], 403);
}
// 3. 獲取關聯設定
$paymentSettings = $machine->paymentConfig->settings ?? [];
$companySettings = $machine->company->settings ?? [];
// 4. 映射 App 預期欄位 (嚴格遵守 HttpAPI.java 結構)
$data = [
't050v01' => $machine->serial_no,
'api_token' => $machine->api_token, // 向 App 核發正式通訊 Token
// 玉山支付
't050v41' => $paymentSettings['esun_store_id'] ?? '',
't050v42' => $paymentSettings['esun_term_id'] ?? '',
't050v43' => $paymentSettings['esun_hash'] ?? '',
// 電子發票 (綠界)
't050v34' => $companySettings['invoice_merchant_id'] ?? '',
't050v35' => $companySettings['invoice_hash_key'] ?? '',
't050v36' => $companySettings['invoice_hash_iv'] ?? '',
't050v38' => $companySettings['invoice_email'] ?? '',
// 趨勢支付 (TrendPay/Greenpay)
'TP_APP_ID' => $paymentSettings['tp_app_id'] ?? '',
'TP_APP_KEY' => $paymentSettings['tp_app_key'] ?? '',
'TP_PARTNER_KEY' => $paymentSettings['tp_partner_key'] ?? '',
// 各類行動支付特店 ID
'TP_LINE_MERCHANT_ID' => $paymentSettings['tp_line_merchant_id'] ?? '',
'TP_PS_MERCHANT_ID' => $paymentSettings['tp_ps_merchant_id'] ?? '',
'TP_EASY_MERCHANT_ID' => $paymentSettings['tp_easy_merchant_id'] ?? '',
'TP_PI_MERCHANT_ID' => $paymentSettings['tp_pi_merchant_id'] ?? '',
'TP_JKO_MERCHANT_ID' => $paymentSettings['tp_jko_merchant_id'] ?? '',
];
return response()->json([
'success' => true,
'code' => 200,
'data' => [$data] // App 預期的是包含單一物件的陣列
]);
}
}

View File

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

466
config/api-docs.php Normal file
View File

@@ -0,0 +1,466 @@
<?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' => 'POST',
'path' => '/api/v1/app/machine/setting/B014',
'description' => '機台引導階段的第二步。在人員登入後,透過此介面下載金流金鑰、電子發票設定與機台專屬通訊 Token。',
'headers' => [
'Authorization' => 'Bearer <user_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'machine' => [
'type' => 'string',
'required' => true,
'description' => '機台序號',
'example' => 'SN202604130001'
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '是否成功',
'example' => true
],
'data' => [
'type' => 'array',
'description' => '配置物件陣列。包含t050v01 (序號), api_token (通訊 Token), t050v41~43 (玉山設定), t050v34~38 (發票設定), TP_... (趨勢/手機支付設定)',
'example' => [
[
't050v01' => 'SN202604130001',
'api_token' => 'mac_token_...',
't050v41' => '80812345',
't050v34' => '2000132',
'TP_APP_ID' => 'GP_001'
]
]
],
],
'request' => [
'machine' => 'SN202604130001'
],
'response' => [
'success' => true,
'code' => 200,
'data' => [
[
't050v01' => 'SN202604130001',
'api_token' => 'mac_token_...',
't050v41' => '80812345',
't050v42' => '9001',
't050v43' => 'hash_key',
't050v34' => '2000132',
'TP_APP_ID' => 'GP_001'
]
]
],
'notes' => '此 API 受 auth:sanctum 保護,必須在 Header 帶上從 B000 取得的 Token。'
],
[
'name' => 'B005: 廣告清單同步 (Ad Sync)',
'slug' => 'b005-ad-sync',
'method' => 'GET',
'path' => '/api/v1/app/machine/ad/B005',
'description' => '用於機台端獲取目前應播放的廣告檔案 URL 清單。此介面無需 Request Body。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
'data' => [
'type' => 'array',
'description' => '廣告物件陣列。內部欄位包含t070v01 (名稱), t070v02 (秒數), t070v03 (位置1:販賣頁, 2:來店禮, 3:待機廣告), t070v04 (URL), t070v05 (順位)',
'example' => [
[
't070v01' => '測試機台廣告',
't070v02' => 15,
't070v03' => 3,
't070v04' => 'https://example.com/ad1.mp4',
't070v05' => 1
]
]
],
],
'request' => [],
'response' => [
'success' => true,
'code' => 200,
'message' => 'OK',
'data' => [
[
't070v01' => '測試機台廣告',
't070v02' => 15,
't070v03' => 3,
't070v04' => 'https://example.com/ad1.mp4',
't070v05' => 1
]
]
],
],
[
'name' => 'B009: 貨道庫存即時回報 (Inventory Report)',
'slug' => 'b009-inventory-report',
'method' => 'PUT',
'path' => '/api/v1/app/products/supplementary/B009',
'description' => '當人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。需進行 RBAC 權限核查。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'account' => [
'type' => 'string',
'required' => true,
'description' => '操作人員帳號',
'example' => '0999123456'
],
'data' => [
'type' => 'array',
'required' => true,
'description' => '貨道數據陣列。tid: 貨道號, t060v00: 商品 ID, num: 庫存量',
'example' => [
['tid' => '1', 't060v00' => '1', 'num' => '10']
]
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '同步是否成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
'message' => [
'type' => 'string',
'description' => '回應訊息',
'example' => 'Slot report synchronized success'
],
'status' => [
'type' => 'string',
'description' => '固定回傳 49 代表同步完成',
'example' => '49'
],
],
'request' => [
'account' => '0999123456',
'data' => [
['tid' => '1', 't060v00' => '1', 'num' => '10']
]
],
'response' => [
'success' => true,
'code' => 200,
'message' => 'Slot report synchronized success',
'status' => '49'
],
],
[
'name' => 'B010: 心跳上報與狀態同步 (Heartbeat)',
'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: 取貨門異常...等。'
]
]
]
]
];

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 秒)上送,用於確認連線狀態、溫度及門禁狀態。

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 週期內執行耗時匯出。

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

BIN
public/S1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

BIN
public/S1_cropped.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
public/S1_edited.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 18 KiB

Some files were not shown because too many files have changed in this diff Show More