Compare commits
81 Commits
main
...
37ef6f1c10
| Author | SHA1 | Date | |
|---|---|---|---|
| 37ef6f1c10 | |||
| 3d24ddff5a | |||
| 9f3a90b2b0 | |||
| 2467d9db7a | |||
| f98d059bc3 | |||
| 7209f9ea98 | |||
| 5c55553905 | |||
| 87ef247a48 | |||
| 38770b080b | |||
| 72812f9b0b | |||
| d2cefe3f39 | |||
| 6588dcd7f7 | |||
| eb73def5f8 | |||
| f00fc940a9 | |||
| 5548bb1cc9 | |||
| 64ac398270 | |||
| 2afcdcebc5 | |||
| fe9c9e0c4a | |||
| c767fe4849 | |||
| caac6e264d | |||
| efafdc747b | |||
| c21cad7f37 | |||
| 3f41896532 | |||
| cd34724c76 | |||
| 7b5a988d60 | |||
| 99243d4206 | |||
| fc79148879 | |||
| 3ce88ed342 | |||
| 1851e91c86 | |||
| 09e1d0dc48 | |||
| 42f96d54c3 | |||
| 56daf8940b | |||
| 39d25ed1d4 | |||
| 78597f1c68 | |||
| 588704642b | |||
| e5516193b0 | |||
| 7f9f76111c | |||
| 3fbb7bc286 | |||
| bb5d212569 | |||
| 6fab048461 | |||
| ea460cf6d9 | |||
| 773396fc90 | |||
| 8ee14eaa29 | |||
| 5708c4f12a | |||
| d679c0df17 | |||
| 03dfcc9c7e | |||
| 75a70a256d | |||
| 682a9e7ac3 | |||
| 02918ce0e1 | |||
| a38387f2ad | |||
| 80436caee7 | |||
| c1b40185eb | |||
| f15038fcbd | |||
| 1d035d2766 | |||
| 1c9edf8e46 | |||
| fba4f26575 | |||
| 4b64fede2e | |||
| d905501a77 | |||
| 56c9a55944 | |||
| c30c3a399d | |||
| 21e064ff91 | |||
| adea7feb7b | |||
| 2a6170b4ce | |||
| 4a1fa2ad1b | |||
| acc81b2156 | |||
| 74b6c71c95 | |||
| 88c3678a4d | |||
| 649cbaab02 | |||
| 9c2ef60463 | |||
| f67a1dc11e | |||
| a578c7f261 | |||
| 84ef0c24e2 | |||
| 55ba08c88f | |||
| 11491e07aa | |||
| d3684385b2 | |||
| 7db3ee3a05 | |||
| a0d107ca79 | |||
| 96b22cd577 | |||
| 3ed8b00cab | |||
| 2aff99fc76 | |||
| 2ed0ee272e |
@@ -1,222 +0,0 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# Backend API Specification (backend.md)
|
||||
|
||||
---
|
||||
|
||||
## 目標範圍
|
||||
|
||||
* 使用 Laravel (RESTful API)
|
||||
* 資料庫:MySQL(migration + seeder)
|
||||
* 提供給現有 Android 團隊的完整 API 規格(JSON 格式)
|
||||
|
||||
---
|
||||
|
||||
## 認證與安全
|
||||
|
||||
* 採用 JWT 或 Laravel Sanctum
|
||||
* 所有需要授權的 API 回傳 401/403 規範
|
||||
* Error 格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"code": 401,
|
||||
"message": "Unauthorized",
|
||||
"errors": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一般回應格式
|
||||
|
||||
成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"message": "OK",
|
||||
"data": { }
|
||||
}
|
||||
```
|
||||
|
||||
錯誤:同上 Error 範例
|
||||
|
||||
---
|
||||
|
||||
## API 清單(建議先開發順序)
|
||||
|
||||
1. Auth (登入/登出/註冊/權杖)
|
||||
2. User / Profile
|
||||
3. Device / Machine (機台管理、狀態回傳、日誌)
|
||||
4. Order / ShoppingCart(若需)
|
||||
5. Notification / Push 訊息
|
||||
6. Activity / Campaign
|
||||
7. Report / Analytics
|
||||
8. Admin CRUD for resources
|
||||
|
||||
---
|
||||
|
||||
## 主要 Endpoints 範例
|
||||
|
||||
### 1) Auth
|
||||
|
||||
* POST /api/v1/auth/login
|
||||
|
||||
* request:
|
||||
|
||||
```json
|
||||
{"email":"user@example.com","password":"pa55"}
|
||||
```
|
||||
|
||||
* response:
|
||||
|
||||
```json
|
||||
{"success":true,"code":200,"data":{"token":"...","user":{"id":1,"name":"..."}}}
|
||||
```
|
||||
|
||||
* POST /api/v1/auth/logout
|
||||
|
||||
* header: Authorization: Bearer <token>
|
||||
* response: 200
|
||||
|
||||
* POST /api/v1/auth/refresh
|
||||
|
||||
---
|
||||
|
||||
### 2) User / Profile
|
||||
|
||||
* GET /api/v1/users/{id}
|
||||
* PUT /api/v1/users/{id}
|
||||
* GET /api/v1/users (admin)
|
||||
|
||||
Request / Response 均採 JSON,個資欄位請遵守最小授權原則。
|
||||
|
||||
---
|
||||
|
||||
### 3) Machine (機台)
|
||||
|
||||
* GET /api/v1/machines
|
||||
|
||||
* Params: page, per_page, status
|
||||
|
||||
* GET /api/v1/machines/{id}
|
||||
|
||||
* POST /api/v1/machines
|
||||
|
||||
* PUT /api/v1/machines/{id}
|
||||
|
||||
* DELETE /api/v1/machines/{id}
|
||||
|
||||
* POST /api/v1/machines/{id}/status
|
||||
|
||||
* 用於下位機或 APP 回傳機台狀態
|
||||
* request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"temperature": 23.4,
|
||||
"status_code": "OK",
|
||||
"firmware_version": "1.2.3",
|
||||
"timestamp": "2025-11-20T15:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
* GET /api/v1/machines/{id}/logs
|
||||
|
||||
---
|
||||
|
||||
### 4) Orders / ShoppingCart
|
||||
|
||||
* POST /api/v1/cart/add
|
||||
* GET /api/v1/cart
|
||||
* POST /api/v1/orders
|
||||
* GET /api/v1/orders/{id}
|
||||
|
||||
支付與第三方串接請另行設計 callback endpoint。
|
||||
|
||||
---
|
||||
|
||||
### 5) Notification
|
||||
|
||||
* POST /api/v1/notifications/send (admin)
|
||||
* GET /api/v1/notifications (user)
|
||||
|
||||
Payload for push could include: title, body, target_user_ids, data
|
||||
|
||||
---
|
||||
|
||||
### 6) Activity / Campaign
|
||||
|
||||
* GET /api/v1/campaigns
|
||||
* POST /api/v1/campaigns
|
||||
* PUT /api/v1/campaigns/{id}
|
||||
|
||||
---
|
||||
|
||||
### 7) Report / Analytics
|
||||
|
||||
* GET /api/v1/reports/machines/summary?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
* GET /api/v1/reports/sales/summary
|
||||
|
||||
Response should include aggregated numbers and paginated lists when needed.
|
||||
|
||||
---
|
||||
|
||||
## Database 基本表(初步)
|
||||
|
||||
* users
|
||||
* roles
|
||||
* machines
|
||||
* machine_logs
|
||||
* orders
|
||||
* order_items
|
||||
* carts
|
||||
* notifications
|
||||
* campaigns
|
||||
* activity_logs
|
||||
* translations (i18n)
|
||||
|
||||
(每張表建議 migration 欄位、index、外鍵,請於開發前定稿)
|
||||
|
||||
---
|
||||
|
||||
## API 規格輸出
|
||||
|
||||
* 建議產出 Swagger (OpenAPI 3.0) 與 Postman Collection
|
||||
* 每個 endpoint 必要欄位、範例、error code、rate limit
|
||||
|
||||
---
|
||||
|
||||
## 測試建議
|
||||
|
||||
* Unit tests:Laravel Feature tests for API
|
||||
* Contract tests:與 Android 團隊一同建立 contract tests(或使用 Postman tests)
|
||||
|
||||
---
|
||||
|
||||
## 部署與運營
|
||||
|
||||
* 建議使用 queue 與 cache(Redis)處理非同步任務
|
||||
* Logging: Sentry or similar
|
||||
* 定期備份 MySQL
|
||||
|
||||
---
|
||||
|
||||
## 交付項目清單
|
||||
|
||||
1. 完整 Laravel 專案(註冊、登入、ACL)
|
||||
2. MySQL migration + seeders
|
||||
3. Swagger/OpenAPI 文件
|
||||
4. Postman Collection
|
||||
5. 後台管理系統(Star Cloud)
|
||||
6. 測試報告
|
||||
7. 部署腳本與上線說明
|
||||
|
||||
---
|
||||
|
||||
*註:本檔為初版 backend.md,若要我把 Excel 的每一列功能自動轉成對應的 API endpoint(含 request/response 範例),我可以直接在此檔案中展開更詳細的 endpoint 清單。*
|
||||
@@ -1,528 +0,0 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
STAR CLOUD 後台管理系統 - 智能販賣機管理平台
|
||||
|
||||
一、儀錶板模組 (Dashboard)
|
||||
1.1 主頁面
|
||||
|
||||
功能描述: 系統總覽與關鍵數據展示
|
||||
主要內容:
|
||||
|
||||
即時銷售數據
|
||||
機台運行狀態
|
||||
庫存警示
|
||||
營收統計圖表
|
||||
|
||||
|
||||
|
||||
|
||||
二、應用程式管理 (Application Management)
|
||||
2.1 個人檔案
|
||||
|
||||
功能描述: 使用者個人資訊管理
|
||||
包含功能:
|
||||
|
||||
基本資料編輯
|
||||
密碼修改
|
||||
通知設定
|
||||
|
||||
|
||||
|
||||
|
||||
三、機台管理模組 (Machine Management)
|
||||
3.1 機台日誌
|
||||
|
||||
功能描述: 機台操作歷史紀錄回溯
|
||||
資料內容:
|
||||
|
||||
操作時間戳記
|
||||
事件類型
|
||||
操作人員
|
||||
詳細描述
|
||||
|
||||
|
||||
|
||||
3.2 機台列表
|
||||
|
||||
功能描述: 所有機台資訊總覽
|
||||
顯示資訊:
|
||||
|
||||
溫度監控
|
||||
下位機狀態
|
||||
刷卡機連線
|
||||
掃碼機狀態
|
||||
機台回傳訊息
|
||||
|
||||
|
||||
|
||||
3.3 機台權限
|
||||
|
||||
功能描述: 機台存取權限控管
|
||||
設定項目:
|
||||
|
||||
人員權限分配
|
||||
操作級別設定
|
||||
|
||||
|
||||
|
||||
3.4 機台稼動率
|
||||
|
||||
功能描述: 機台運行效率分析
|
||||
統計數據:
|
||||
|
||||
運行時間
|
||||
停機時間
|
||||
稼動率百分比
|
||||
|
||||
|
||||
|
||||
3.5 效期管理
|
||||
|
||||
功能描述: 商品效期與貨道出貨控制
|
||||
管理項目:
|
||||
|
||||
設定貨道是否可出貨
|
||||
效期到期提醒
|
||||
商品下架設定
|
||||
|
||||
|
||||
|
||||
3.6 維修管理單
|
||||
|
||||
功能描述: 機台維修工單系統
|
||||
包含功能:
|
||||
|
||||
報修單建立
|
||||
維修進度追蹤
|
||||
維修歷史紀錄
|
||||
|
||||
|
||||
|
||||
3.7 機台管理擴充欄位
|
||||
|
||||
新增欄位:
|
||||
|
||||
保固區間
|
||||
交機日期
|
||||
租賃區間
|
||||
保內/保外狀態顯示
|
||||
|
||||
|
||||
|
||||
3.8 機台設定參數
|
||||
|
||||
刷卡機秒數: 刷卡逾時設定
|
||||
卡機結帳時間: 結帳流程時間限制
|
||||
卡機結帳時間2: 備用結帳時間設定
|
||||
金流緩衝時間: 金流處理緩衝時間
|
||||
刷卡機編號: 刷卡機裝置識別
|
||||
發票狀態碼: 發票開立狀態管理
|
||||
|
||||
3.9 APP到期提醒
|
||||
|
||||
功能描述: APP授權到期通知系統
|
||||
|
||||
|
||||
四、APP管理模組 (APP Management)
|
||||
4.1 UI元素設定
|
||||
|
||||
功能描述: APP版面配置設定
|
||||
注意事項: 與新版差異較大,需特別處理
|
||||
|
||||
4.2 小幫手設定
|
||||
|
||||
功能描述: APP內建輔助功能設定
|
||||
|
||||
4.3 問卷設定
|
||||
|
||||
功能描述: 互動問卷建立與管理
|
||||
|
||||
4.4 互動遊戲設定
|
||||
|
||||
功能描述: APP互動遊戲配置
|
||||
|
||||
4.5 計時器
|
||||
|
||||
功能描述: 時間相關功能設定
|
||||
|
||||
|
||||
五、倉庫管理模組 (Warehouse Management)
|
||||
5.1 倉庫列表
|
||||
|
||||
5.1.1 倉庫列表(全部): 顯示所有倉庫
|
||||
5.1.2 倉庫列表(個人): 顯示個人負責倉庫
|
||||
|
||||
5.2 庫存管理單
|
||||
|
||||
功能描述: 倉庫庫存異動管理
|
||||
|
||||
5.3 調撥單
|
||||
|
||||
功能描述: 倉庫間商品調撥作業
|
||||
|
||||
5.4 採購單
|
||||
|
||||
功能描述: 商品採購申請與管理
|
||||
|
||||
5.5 機台補貨管理
|
||||
|
||||
5.5.1 機台補貨單: 補貨工單建立
|
||||
5.5.2 機台補貨紀錄: 個別補貨歷史
|
||||
5.5.3 機台補貨紀錄(總): 所有補貨總覽
|
||||
|
||||
5.6 庫存查詢
|
||||
|
||||
5.6.1 機台庫存: 各機台即時庫存
|
||||
5.6.2 人員庫存: 人員持有庫存
|
||||
|
||||
5.7 回庫單
|
||||
|
||||
功能描述: 商品退回倉庫管理
|
||||
|
||||
|
||||
六、銷售管理模組 (Sales Management)
|
||||
6.1 銷售&金流紀錄
|
||||
|
||||
功能描述: 銷售交易與金流明細
|
||||
包含項目:
|
||||
|
||||
現金出貨 API
|
||||
發票系統整合
|
||||
各種出貨方式整理
|
||||
|
||||
|
||||
|
||||
6.2 取貨碼設定
|
||||
|
||||
功能描述: 取貨驗證碼管理
|
||||
|
||||
6.3 購買單
|
||||
|
||||
功能描述: 購買訂單管理
|
||||
|
||||
6.4 促銷時段設定
|
||||
|
||||
功能描述: 促銷活動時間設定
|
||||
重要功能: (W) 重啟掃描商品 API
|
||||
|
||||
6.5 通行碼設定
|
||||
|
||||
功能描述: 特殊通行碼權限管理
|
||||
|
||||
6.6 來店禮設定
|
||||
|
||||
功能描述: 來店優惠活動設定
|
||||
包含: 來店禮開關控制
|
||||
|
||||
|
||||
七、分析管理模組 (Analysis Management)
|
||||
7.1 零錢庫存分析
|
||||
|
||||
功能描述: 機台零錢數量監測與分析
|
||||
|
||||
7.2 機台報表分析
|
||||
|
||||
功能描述: 機台運營數據分析報表
|
||||
|
||||
7.3 商品報表分析
|
||||
|
||||
功能描述: 商品銷售數據分析
|
||||
|
||||
7.4 互動問卷分析
|
||||
|
||||
功能描述: 問卷結果統計與分析
|
||||
|
||||
|
||||
八、稽核管理模組 (Audit Management)
|
||||
8.1 採購單稽核
|
||||
|
||||
功能描述: 採購單審核流程
|
||||
|
||||
8.2 調撥單稽核
|
||||
|
||||
功能描述: 調撥單審核流程
|
||||
|
||||
8.3 補貨單稽核
|
||||
|
||||
功能描述: 補貨單審核流程
|
||||
|
||||
|
||||
九、資料設定模組 (Data Configuration)
|
||||
9.1 機台管理
|
||||
|
||||
功能描述: 機台基本資料設定
|
||||
|
||||
9.2 商品管理
|
||||
|
||||
功能描述: 商品資料維護
|
||||
|
||||
9.3 廣告管理
|
||||
|
||||
功能描述: 機台廣告影片管理
|
||||
用途: 機台可讀取後台廣告影片
|
||||
|
||||
9.4 管理者可賣商品
|
||||
|
||||
功能描述: 管理者商品銷售權限
|
||||
|
||||
9.5 帳號管理
|
||||
|
||||
功能描述: 主帳號管理
|
||||
|
||||
9.6 子帳號管理
|
||||
|
||||
功能描述: 子帳號建立與管理
|
||||
|
||||
9.7 子帳號角色管理
|
||||
|
||||
功能描述: 子帳號權限角色設定
|
||||
|
||||
9.8 點數設定
|
||||
|
||||
功能描述: 客戶點數系統設定
|
||||
特殊功能: 支援客戶自行新增點數
|
||||
|
||||
9.9 識別證管理
|
||||
|
||||
功能描述: 識別證資料管理
|
||||
用途: 安霸系統使用
|
||||
|
||||
|
||||
十、遠端管理模組 (Remote Management)
|
||||
10.1 機台庫存
|
||||
|
||||
功能描述: 遠端修改機台庫存
|
||||
|
||||
10.2 機台重啟
|
||||
|
||||
功能描述: 遠端重啟機台系統
|
||||
|
||||
10.3 卡機重啟
|
||||
|
||||
功能描述: 遠端重啟刷卡機
|
||||
|
||||
10.4 遠端結帳
|
||||
|
||||
功能描述: 遠端執行結帳流程
|
||||
|
||||
10.5 遠端鎖定頁
|
||||
|
||||
功能描述: 遠端鎖定機台頁面
|
||||
|
||||
10.6 遠端找零
|
||||
|
||||
功能描述: 遠端執行找零功能
|
||||
|
||||
10.7 遠端出貨
|
||||
|
||||
功能描述: 遠端控制商品出貨
|
||||
|
||||
|
||||
十一、Line管理模組 (Line Integration)
|
||||
11.1 Line會員管理
|
||||
|
||||
功能描述: Line會員資料管理
|
||||
|
||||
11.2 Line機台管理
|
||||
|
||||
功能描述: Line綁定機台管理
|
||||
|
||||
11.3 Line商品管理
|
||||
|
||||
功能描述: Line商城商品設定
|
||||
|
||||
11.4 Line生活圈
|
||||
|
||||
功能描述: Line官方帳號整合
|
||||
|
||||
11.5 Line商城訂單
|
||||
|
||||
功能描述: Line商城訂單管理
|
||||
|
||||
11.6 Line優惠券
|
||||
|
||||
功能描述: Line優惠券發放與管理
|
||||
|
||||
|
||||
十二、預約系統模組 (Reservation System)
|
||||
12.1 Line會員管理
|
||||
|
||||
功能描述: 預約系統會員管理
|
||||
|
||||
12.2 Line店家管理
|
||||
|
||||
功能描述: 店家資訊設定
|
||||
|
||||
12.3 Line時段組合
|
||||
|
||||
功能描述: 預約時段設定
|
||||
|
||||
12.4 Line場地管理
|
||||
|
||||
功能描述: 場地資源管理
|
||||
|
||||
12.5 Line優惠券管理
|
||||
|
||||
功能描述: 預約優惠券管理
|
||||
|
||||
12.6 Line預約管理
|
||||
|
||||
功能描述: 預約單管理
|
||||
|
||||
12.7 Line訂單管理
|
||||
|
||||
功能描述: 預約訂單處理
|
||||
|
||||
|
||||
十三、特殊權限管理模組 (Special Permissions)
|
||||
13.1 庫存清空
|
||||
|
||||
功能描述: 特殊權限庫存清空功能
|
||||
|
||||
13.2 APK版本管理
|
||||
|
||||
功能描述: APP版本控制與更新
|
||||
|
||||
13.3 Discord通知設定
|
||||
|
||||
功能描述: Discord通知整合設定
|
||||
|
||||
|
||||
十四、權限設定模組 (Permission Management)
|
||||
14.1 功能權限設定
|
||||
|
||||
14.1.1 APP功能管理: APP功能權限
|
||||
14.1.2 資料設定: 資料設定權限
|
||||
14.1.3 銷售管理: 銷售管理權限
|
||||
14.1.4 機台管理: 機台管理權限
|
||||
14.1.5 倉庫管理: 倉庫管理權限
|
||||
14.1.6 分析管理: 分析管理權限
|
||||
14.1.7 稽核管理: 稽核管理權限
|
||||
14.1.8 遠端管理: 遠端管理權限
|
||||
14.1.9 Line管理: Line管理權限
|
||||
|
||||
14.2 權限角色設定
|
||||
|
||||
功能描述: 角色權限組合設定
|
||||
|
||||
14.3 其他功能管理
|
||||
|
||||
功能描述: 其他特殊功能權限
|
||||
|
||||
14.4 AI智能預測
|
||||
|
||||
功能描述: AI功能權限設定
|
||||
|
||||
|
||||
資料庫設計建議
|
||||
主要資料表規劃
|
||||
machines # 機台資料表
|
||||
├── warehouses # 倉庫資料表
|
||||
├── products # 商品資料表
|
||||
├── machine_stocks # 機台庫存表
|
||||
├── warehouse_stocks # 倉庫庫存表
|
||||
├── sales_records # 銷售紀錄表
|
||||
├── purchase_orders # 採購單表
|
||||
├── transfer_orders # 調撥單表
|
||||
├── replenishment_orders # 補貨單表
|
||||
├── maintenance_orders # 維修單表
|
||||
├── machine_logs # 機台日誌表
|
||||
├── users # 使用者表
|
||||
├── roles # 角色表
|
||||
├── permissions # 權限表
|
||||
├── line_members # Line會員表
|
||||
├── reservations # 預約表
|
||||
└── advertisements # 廣告表
|
||||
|
||||
API 端點規劃
|
||||
機台管理 API
|
||||
|
||||
GET /api/machines - 取得機台列表
|
||||
POST /api/machines - 新增機台
|
||||
PUT /api/machines/{id} - 更新機台
|
||||
DELETE /api/machines/{id} - 刪除機台
|
||||
GET /api/machines/{id}/logs - 取得機台日誌
|
||||
POST /api/machines/{id}/restart - 遠端重啟
|
||||
|
||||
倉庫管理 API
|
||||
|
||||
GET /api/warehouses - 取得倉庫列表
|
||||
GET /api/warehouses/{id}/stocks - 取得倉庫庫存
|
||||
POST /api/transfer-orders - 建立調撥單
|
||||
POST /api/purchase-orders - 建立採購單
|
||||
POST /api/replenishment-orders - 建立補貨單
|
||||
|
||||
銷售管理 API
|
||||
|
||||
GET /api/sales - 取得銷售紀錄
|
||||
POST /api/sales/cash - 現金出貨
|
||||
GET /api/sales/invoice - 發票查詢
|
||||
POST /api/pickup-codes - 建立取貨碼
|
||||
|
||||
遠端控制 API
|
||||
|
||||
POST /api/remote/checkout - 遠端結帳
|
||||
POST /api/remote/dispense - 遠端出貨
|
||||
POST /api/remote/change - 遠端找零
|
||||
POST /api/remote/lock - 遠端鎖定
|
||||
|
||||
|
||||
技術架構建議
|
||||
後端技術
|
||||
|
||||
框架: Laravel 10+
|
||||
資料庫: MySQL 8.0+
|
||||
快取: Redis
|
||||
佇列: Laravel Queue (Redis Driver)
|
||||
API文件: Swagger/OpenAPI
|
||||
|
||||
前端技術
|
||||
|
||||
模板引擎: Blade
|
||||
CSS框架: Tailwind CSS
|
||||
JavaScript: Alpine.js / Vue.js
|
||||
圖表庫: Chart.js / ApexCharts
|
||||
|
||||
第三方整合
|
||||
|
||||
Line API: Line Messaging API
|
||||
Discord: Discord Webhook
|
||||
金流: 藍新、綠界等
|
||||
發票: 電子發票整合
|
||||
|
||||
|
||||
開發優先順序建議
|
||||
Phase 1 - 核心功能 (1-2個月)
|
||||
|
||||
使用者認證與權限系統
|
||||
機台管理基本功能
|
||||
倉庫管理基本功能
|
||||
銷售紀錄查詢
|
||||
|
||||
Phase 2 - 進階功能 (2-3個月)
|
||||
|
||||
遠端控制功能
|
||||
報表分析功能
|
||||
稽核流程
|
||||
APP管理功能
|
||||
|
||||
Phase 3 - 整合功能 (1-2個月)
|
||||
|
||||
Line整合
|
||||
預約系統
|
||||
AI智能預測
|
||||
Discord通知
|
||||
|
||||
|
||||
注意事項
|
||||
|
||||
安全性: 所有遠端控制功能需要雙重驗證
|
||||
權限控管: 嚴格的角色權限分離
|
||||
日誌記錄: 所有重要操作需記錄日誌
|
||||
API限流: 防止API濫用
|
||||
資料備份: 定期自動備份機制
|
||||
錯誤處理: 完善的異常處理機制
|
||||
測試: 重要功能需撰寫測試案例
|
||||
RetryClaude can make mistakes. Please double-check responses.
|
||||
83
.agents/rules/api-rules.md
Normal file
83
.agents/rules/api-rules.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# Backend API Specification (api-rules.md)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 1. 目標範圍與分類
|
||||
|
||||
本系統 API 分為兩大類,遵循不同的設計慣例:
|
||||
|
||||
* **Admin/Web API (`/api/v1/...`)**: 供後台管理介面、APP UI 使用。遵循標準 RESTful 與 JSON 結構。
|
||||
* **Machine IoT API (`/api/app/...`)**: 供販賣機、計時器等硬體端點使用。需相容既有 PDF 規格(如 B010, B600),欄位命名多為 `req1`, `req2` 或特定縮寫。
|
||||
|
||||
---
|
||||
|
||||
## 🔐 2. 認證與安全性
|
||||
|
||||
* **Admin API**: 採用 **Laravel Sanctum (Session/Token)** 認證。
|
||||
* **Machine IoT API**:
|
||||
* **核心機制**: 必須在 Header 帶入 `Authorization: Bearer <api_token>` 進行身份驗證。
|
||||
* **Phase 1 (兼容模式)**: 若為相容既有機動硬體,可暫時接受 Request 包含 `key` 欄位,但後端應過渡至 Bearer 驗證。
|
||||
* **安全性強化**: 改用每台機台專屬的 `api_token` (透過 B010 初始化或派發),並配合 `serial_no` (即 `workid`) 進行資料歸屬權驗證。
|
||||
* **傳輸安全**: 必須強制使用 **HTTPS**。
|
||||
|
||||
---
|
||||
|
||||
## 📦 3. 回應格式規範
|
||||
|
||||
### 3.1 標準回應 (Admin/Web API)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"message": "OK",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 IoT 指令回應 (B010/B055 等)
|
||||
機台端通常透過 response 的 `status` 欄位或特定的 `message` 字串來執行動作:
|
||||
* **成功但有指令**: `{"status": "49", "message": "reload B017"}`
|
||||
* **純資料回傳**: 直接返回對象陣列或 PDF 定義的欄位。
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 4. 主要 Endpoints 與命名慣例
|
||||
|
||||
### 4.1 管理類 (Admin UI)
|
||||
* `GET /api/v1/users`: 管理員清單
|
||||
* `GET /api/v1/machines`: 機台清單
|
||||
* `PUT /api/v1/machines/{id}`: 更新機台參數
|
||||
* 遵循 **kebab-case** 路由與 **snake_case** JSON 欄位。
|
||||
|
||||
### 4.2 終端類 (IoT / Machine) — 須嚴格遵守 PDF 規格
|
||||
* **API 識別碼 (workid)**: URL 中的 `{workid}` 參數固定為該 API 的功能代碼 (如 `B010`, `B017`, `B600`),不隨機台改變。
|
||||
* **機台識別方式**:
|
||||
1. **Header**: 透過 `Authorization: Bearer <api_token>` 識別。
|
||||
2. **Request Body**: 透過 `machine` 或 `serial_no` 等欄位識別具體機台。
|
||||
* **主要 Endpoint 範例**:
|
||||
* **心跳上報 (B010)**: `POST /api/app/machine/status/B010`
|
||||
* **交易回傳 (B600)**: `POST /api/app/B600` (Body 欄位 `req2` 為機台編號)
|
||||
* **貨道庫存 (B017)**: `POST /api/app/machine/reload_msg/B017`
|
||||
* **遠端出貨 (B055)**: `POST /api/app/machine/dispense/B055`
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 5. 高併發處理與隊列
|
||||
|
||||
為了系統穩定性,以下 API **嚴禁直寫資料庫**,必須進入 **Redis Queue** 異步處理:
|
||||
1. **B010**: 心跳上傳(每 5-10 秒一次)。
|
||||
2. **B600 / B602**: 交易與出貨紀錄。
|
||||
3. **B220**: 零錢機庫存變動。
|
||||
4. **B710**: 計時器狀態同步。
|
||||
|
||||
後端應立即回傳 `202 Accepted` 或業務定義的成功碼,由 Job 背景完成數據持久化。
|
||||
|
||||
---
|
||||
|
||||
## 📄 6. 交付與文件
|
||||
* **OpenAPI**: 應區分 `admin.yaml` 與 `iot.yaml`。
|
||||
* **Postman**: 提供帶有環境變數(機台金鑰、Base URL)的 Collection。
|
||||
97
.agents/rules/framework.md
Normal file
97
.agents/rules/framework.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# 開發框架規範說明書:Cloud 後台管理系統 (star-cloud)
|
||||
|
||||
## 1. 專案概述
|
||||
* **目標**:打造一個強大且穩定的智能販賣機後台管理系統(Cloud 平台),負責管理機台、商品、銷售數據以及提供給端點機台串接的 API。
|
||||
* **核心架構**:採用 **傳統單體式架構 (Monolithic Architecture)** 配 Laravel Blade 模板引擎進行伺服器端渲染 (SSR)。
|
||||
* **工作流程**:後端處理業務邏輯與資料庫存取,並透過 Blade 引擎渲染包含 Tailwind CSS 類別的 HTML。前端互動行為由輕量級 Alpine.js 負責,UI 元件以 Preline UI 為主體。
|
||||
|
||||
## 2. 技術棧 (Tech Stack)
|
||||
* **後端框架**:PHP 8.5 / Laravel 12
|
||||
* **核心組件**:Redis (用於高併發 IoT 隊列與快取,為系統穩定之必要條件)
|
||||
* **前端視圖 (View)**:Laravel Blade
|
||||
* **前端互動 (JS)**:Alpine.js (專注於行為,不負責渲染)
|
||||
* **介面與樣式 (CSS)**:Tailwind CSS + Preline UI (直接寫作於 Blade 模板中)。
|
||||
* **重要規範**:Preline UI 僅作為「原子組件」與「JS 互動邏輯」的參考庫。整體的「佈局」與「美學」必須嚴格遵守「極簡奢華風 UI 實作規範 (SKILL.md)」。
|
||||
* **前端建置工具**:Vite
|
||||
* **資料庫**:MySQL 8.0
|
||||
* **開發環境**:Laravel Sail (Docker / WSL2)
|
||||
|
||||
## 3. 目錄結構與慣例
|
||||
|
||||
### 3.1 後端 (Laravel)
|
||||
與標準 Laravel 結構保持一致,無過度拆分的模組化(與 ERP 的 Modular Monolith 區別):
|
||||
* **Controllers**:`app/Http/Controllers/`,負責接收請求並回傳 `view()` 或 JSON。
|
||||
* **Models**:`app/Models/{Domain}/`,按領域分群 (例如 `Machine`, `Member`, `System`)。
|
||||
* **Routes**:`routes/web.php` 用於後台管理介面;`routes/api.php` 提供外部或機台調用介面 (需 V1 版本化)。
|
||||
* **Services** (建議):`app/Services/{Domain}/`,將商業邏輯與資料異動封裝於 Service 中。
|
||||
* **Traits**:`app/Traits/ApiResponse.php` 用於統一 API JSON 回傳格式。
|
||||
* **Jobs**:`app/Jobs/{Domain}/`,**高併發 IoT 場景之必要實作**。所有日誌、心跳上報必須進入 Redis Queue 進行背景異步處理,嚴禁在 API 直連 DB 寫入日誌。
|
||||
|
||||
### 3.2 前端 (Blade / Tailwind / Alpine)
|
||||
* **Views (頁面)**:位於 `resources/views/`。通常依功能建立資料夾(如 `resources/views/admin/machines/index.blade.php`)。
|
||||
* **Layouts (版面)**:位於 `resources/views/layouts/`。定義全站的共用版面結構(如 header, sidebar, footer)。
|
||||
* **Components (組件)**:位於 `resources/views/components/`。封裝可重用的 Blade 元件(如 Button, Modal, Table),支援透過 `<x-button>` 語法呼叫。
|
||||
|
||||
## 4. 開發標準 (Coding Standards)
|
||||
* **命名規範**:
|
||||
* Controllers: `PascalCaseController.php` (例如 `MachineController.php`)
|
||||
* Models: `PascalCase.php` (例如 `Machine.php`)
|
||||
* Blade Views: `kebab-case.blade.php` 或按資源名稱 (例如 `index.blade.php`, `create.blade.php`)
|
||||
* Routes uri: `kebab-case` (例如 `/machine-logs`)
|
||||
* **回傳格式**:
|
||||
* Web 路由:回傳 `view()`,表單驗證失敗時直接使用 Laravel 內建的 redirect with errors。
|
||||
* API 路由:回傳標準 JSON 格式的 `JsonResponse`。
|
||||
|
||||
## 5. UI 與前端開發指南
|
||||
* **樣式撰寫**:全面使用 Tailwind CSS utility classes,**避免撰寫自訂 CSS**(除非少數特定動畫或覆寫)。
|
||||
* **UI 元件庫**:遵循 **Preline UI** 的類別與 HTML 結構進行開發。
|
||||
* **前端腳本**:
|
||||
* 優先使用 **Alpine.js** (`x-data`, `x-show`, `@click` 等) 在 HTML 標籤內完成簡單的 DOM 狀態切換與互動邏輯。
|
||||
* 避免在 Blade 內撰寫冗長的 `<script>` Vanilla JS;若邏輯過於複雜,可將 Alpine state 獨立成 js 檔案再於 Vite 引入,但原則上保持輕量。
|
||||
|
||||
## 6. 多語系 I18n 規範 (Multi-language Standards)
|
||||
* **視圖開發**:所有使用者可見的文字、按鈕、提示訊息,必須使用 Laravel 的 `@lang('key')` 或 `__('key')` 函式包裹。
|
||||
* **語系 Key 命名**:語系 Key 必須採用 **英文原始詞彙 (English phrases)** 作為 Key 名稱為原則,以提高代碼可讀性並作為預設回退(除非該字串過長,才建議使用點號分隔的 key)。
|
||||
* 範例:使用 `__('Account Settings')`。
|
||||
* **翻譯檔維護**:
|
||||
* 主語系檔案位於 `lang/` 目錄。
|
||||
* 開發新功能時,必須同步更新以下三個 JSON 翻譯檔:`zh_TW.json` (主要)、`en.json` (預設)、`ja.json` (日文)。
|
||||
|
||||
## 7. AI 協作規則 (給 Antigravity AI)
|
||||
* **角色設定**:你是一位專業的全端開發工程師助手。
|
||||
* **代碼生成指令**:
|
||||
* 所有的解釋說明請使用 **繁體中文**。
|
||||
* **【警告:Preline 冗餘】** Preline UI 的官方範例常包含多餘的控制項(如頂部筆數切換)。**嚴禁**照抄其佈局,必須確保頂部工具列(Header/Toolbar)維持極簡,重複功能一律收納至底部。
|
||||
* **【警告】** 此專案前端禁用 React / Vue / Inertia.js。所有的前端頁面生成必須使用 **Blade 模板** 結合 **Tailwind CSS** 與 **Alpine.js**。
|
||||
* **【多語系強制要求】** 任何新增的 Blade UI 區塊,禁止硬編碼 (Hard-coded) 中文或英文。必須使用 `__('...')` 並同步在 `lang/*.json` 補上翻譯。
|
||||
* 生成 UI 區塊時,必須優先參考與產生 **Preline UI** 風格與結構的標記語法。
|
||||
* 開發新功能時,請建立標準的 Controller 搭配對應的 `resources/views/.../` 目錄。
|
||||
|
||||
## 8. 運行機制 (Docker / Sail)
|
||||
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
|
||||
|
||||
* **啟動環境**:`./vendor/bin/sail up -d`
|
||||
* **執行 PHP 指令**:`./vendor/bin/sail php -v`
|
||||
* **執行 Artisan 指令**:`./vendor/bin/sail artisan route:list`
|
||||
* **執行 Composer**:`./vendor/bin/sail composer install`
|
||||
* **執行 Node/NPM**:`./vendor/bin/sail npm run dev`
|
||||
|
||||
## 8. 部署與查修環境 (CI/CD)
|
||||
* **自動化部署**:專案具備基於 Gitea Actions 的 CI/CD 自動化部署流程 (`.gitea/workflows/`)。
|
||||
* **Demo 環境 (對應 `demo` 分支)**:
|
||||
* 透過 `deploy-demo.yaml`,合併或推送到 `demo` 分支會自動部署至 `demo-cloud.taiwan-star.com.tw`。
|
||||
* 登入伺服器查修:`ssh gitea_work`,路徑為 `/var/www/star-cloud-demo`。
|
||||
|
||||
## 9. 瀏覽器測試規範 (Browser Testing)
|
||||
當需要進行瀏覽器自動化測試或手動驗證時,必須遵守以下連線資訊:
|
||||
|
||||
* **本地測試網址**:`http://localhost:8090/` (注意:非 8000 或 8080)
|
||||
* **預設管理員帳號**:`admin`
|
||||
* **預設管理員密碼**:`Star82779061`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 在執行 `open_browser_url` 或進行 E2E 測試時,請務必優先確認 Port 是否為 `8090`,以避免連線至錯誤的服務環境。
|
||||
91
.agents/rules/rbac-rules.md
Normal file
91
.agents/rules/rbac-rules.md
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# 多租戶與權限架構實作規範 (RBAC Rules)
|
||||
|
||||
本文件定義 Star Cloud 系統的多租戶與權限(RBAC)實作標準,開發者必須嚴格遵守以下準則,以確保資料隔離與安全性。
|
||||
|
||||
---
|
||||
|
||||
## 1. 資料隔離核心 (Data Isolation)
|
||||
|
||||
### 1.1 租戶欄位 (`company_id`)
|
||||
任何屬於租戶資源的資料表(如 `users`, `machines`, `transactions` 等),**必須**包含 `company_id` 欄位。
|
||||
- `company_id = null`:系統管理員(SaaS 平台營運商)。
|
||||
- `company_id = {ID}`:特定租戶。
|
||||
|
||||
### 1.2 自動過濾 (Global Scopes)
|
||||
- 資源 Model 必須套用 `TenantScoped` Trait。
|
||||
- 當非系統管理員登入時,所有 Eloquent 查詢必須自動加上 `where('company_id', auth()->user()->company_id)`。
|
||||
- **嚴禁**在 Controller 手動撰寫重複的過濾邏輯,除非是複雜的 Raw SQL。
|
||||
|
||||
### 1.3 寫入安全
|
||||
- 建立新資源時,必須在背景強制綁定 `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)
|
||||
|
||||
### 2.1 租戶感知角色 (Tenant-Aware Roles)
|
||||
- `roles` 資料表已擴充 `company_id` 欄位。
|
||||
- 撈取角色清單供指派時,必須過濾 `company_id` 或為 null 的系統預設角色。
|
||||
|
||||
### 2.2 權限命名
|
||||
- 權限名稱應遵循 `[module].[action]` 格式(例如 `machine.view`, `machine.edit`)。
|
||||
- 所有租戶共用相同的權限定義。
|
||||
|
||||
### 2.3 權限遞迴約束 (Privilege Delegation Constraint)
|
||||
為防止權限提升 (Privilege Escalation):
|
||||
- **權限子集驗證**:管理員僅能指派其**自身持有**之權限給其他角色或帳號。
|
||||
- **Controller 實作**:在 `store` 或 `update` 時,必須比對傳入的權限集合是否為操作者 `getPermissionNames()` 的子集。
|
||||
- **UI 過濾**:權限分配介面應基於當前使用者權限清單進行動態過濾展示。
|
||||
|
||||
---
|
||||
|
||||
## 3. 介面安全 (UI/Blade)
|
||||
|
||||
### 3.1 身份判定 Helper
|
||||
使用以下方法進行區分:
|
||||
- `$user->isSystemAdmin()`: 判斷是否為平台營運人員。
|
||||
- `$user->isTenant()`: 判斷是否為租戶帳號。
|
||||
|
||||
### 3.2 Blade 指令
|
||||
- 涉及全站管理或跨租戶功能,必須使用 `@if(auth()->user()->isSystemAdmin())` 包裹。
|
||||
- 確保租戶登入時,不會在 Sidebar 或選單看到不屬於其權限範圍的項目。
|
||||
|
||||
---
|
||||
|
||||
## 4. API 安全
|
||||
- 所有的 API Route 應預設包含 `CheckTenantAccess` Middleware。
|
||||
- 嚴禁透過 URL 修改 ID 存取不屬於該租戶的資料,必須依賴 `company_id` 的 Scope 過濾。
|
||||
|
||||
---
|
||||
|
||||
## 5. 客戶初次建立與角色初始化 (Role Provisioning)
|
||||
|
||||
### 5.1 初始角色建立
|
||||
當系統管理員為新客戶(該租戶尚未有任何角色)建立第一個帳號時,應遵循以下邏輯:
|
||||
1. **選取範本**:從系統預設的「全域角色範本」(`company_id = null` 且 `is_system = 0`)中選取一個作為基礎。
|
||||
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)」。
|
||||
44
.agents/rules/skill-trigger.md
Normal file
44
.agents/rules/skill-trigger.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# 技能觸發規範 (Skill Trigger Rules)
|
||||
|
||||
本文件確保 AI 助手在對話中能**主動辨識**需要參照技能 (Skill) 的時機。
|
||||
Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
||||
**若對話內容命中以下任一觸發條件,必須先使用 `view_file` 讀取對應的 `SKILL.md` 後標記為 active 再進行作業。**
|
||||
|
||||
---
|
||||
|
||||
## 觸發對照表
|
||||
|
||||
| 觸發詞 / 情境 | 對應 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` |
|
||||
| 查詢、撈資料、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` |
|
||||
|
||||
---
|
||||
|
||||
## 強制觸發場景
|
||||
|
||||
以下場景**無論對話中是否出現觸發詞**,都必須主動載入對應 Skill:
|
||||
|
||||
### 🔴 新增或修改頁面 (Views/Blade) 時
|
||||
必須讀取:
|
||||
1. **ui-minimal-luxury** — 確保符合極簡奢華風視覺與互動規範
|
||||
2. **rbac-rules** — 確認 UI 區塊的權限顯示控制
|
||||
|
||||
### 🔴 新增機台通訊 API 端點時
|
||||
必須讀取:
|
||||
1. **iot-communication** — 決定是否使用異步隊列流程
|
||||
|
||||
### 🔴 修改 Job 或 Service 邏輯時
|
||||
必須讀取:
|
||||
1. **iot-communication** — 確保符合高併發處理架構
|
||||
|
||||
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
|
||||
必須讀取:
|
||||
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
|
||||
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用
|
||||
63
.agents/skills/iot-communication/SKILL.md
Normal file
63
.agents/skills/iot-communication/SKILL.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: IoT 通訊與高併發處理規範
|
||||
description: 規範智能販賣機與 Cloud 平台間的高頻通訊處理流程,包含 API 接收、異步隊列、業務邏輯拆分與日誌記錄。
|
||||
---
|
||||
|
||||
# IoT 通訊與高併發處理規範 (IoT Communication Skill)
|
||||
|
||||
本規範確保 Star-Cloud 系統在處理成千上萬台機台的高頻發報時,能維持伺服器響應速度與資料一致性。
|
||||
|
||||
## 1. 處理管線 (Processing Pipeline)
|
||||
|
||||
所有來自機台的非即時性資料(日誌、心跳、狀態上報)必須遵循以下 pipeline:
|
||||
|
||||
1. **API Controller (接收層)**:驗證 Request 合法性,隨即分派 (Dispatch) 任務至 Queue,並回傳 `202 Accepted`。
|
||||
2. **Job (異步層)**:由背景 Worker 讀取隊列任務,呼叫對應 Service 處理。
|
||||
3. **Service (邏輯層)**:封裝商業邏輯,更新資料庫。
|
||||
4. **Model (儲存層)**:執行資料存取。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **嚴禁**在 API Controller 直接進行資料庫寫入操作(針對機台發訊端點)。
|
||||
|
||||
## 2. 異步任務實作範例
|
||||
|
||||
### 2.1 API Endpoint
|
||||
```php
|
||||
public function storeLog(Request $request, int $id): JsonResponse
|
||||
{
|
||||
// 1. 驗證
|
||||
$data = $request->validate([...]);
|
||||
|
||||
// 2. 異步分派
|
||||
ProcessMachineLog::dispatch($id, $data);
|
||||
|
||||
// 3. 快速回應
|
||||
return $this->successResponse([], 'Accepted', 202);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Job 處理邏輯
|
||||
Job 應保持單純,僅作為 Service 的觸發點:
|
||||
```php
|
||||
public function handle(MachineService $service): void
|
||||
{
|
||||
$service->recordLog($this->machineId, $this->logData);
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 隊列配置規範
|
||||
|
||||
- **連接驅動 (Driver)**:預設使用 `Redis` 以確保高併發吞吐量。
|
||||
- **重試機制 (Retry)**:IoT 任務應設定合理的重試次數,避免因網路短暫波動遺失日誌。
|
||||
- **失敗處理 (Failed Jobs)**:關鍵業務(如訂單同步)必須監控 `failed_jobs`。
|
||||
|
||||
## 4. 速率限制 (Rate Limiting)
|
||||
|
||||
- 所有的 IoT API 必須在 `routes/api.php` 中使用 `throttle:api` 或自定義 Middleware。
|
||||
- 針對單一機台 ID 應限制其每一分鐘的最高連線數,防止遭受攻擊或機台 Bug 導致的連線暴衝。
|
||||
|
||||
## 5. 檢核項目 (Checklist)
|
||||
- [ ] 是否使用了 `ApiResponse` Trait?
|
||||
- [ ] 業務邏輯是否已封裝至 `App\Services`?
|
||||
- [ ] 是否使用了 Redis Queue 進行非同步處理?
|
||||
- [ ] 是否在 API 層級進行了基礎的參數驗證?
|
||||
331
.agents/skills/ui-minimal-luxury/SKILL.md
Normal file
331
.agents/skills/ui-minimal-luxury/SKILL.md
Normal file
@@ -0,0 +1,331 @@
|
||||
---
|
||||
name: 極簡奢華風 UI 實作規範 (Minimal Luxury UI)
|
||||
description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範,包含 CSS Tokens、常用組件樣式、動畫效果與互動模式,確保全站 15+ 模組的視覺一致性。
|
||||
---
|
||||
|
||||
# 極簡奢華風 UI 實作規範 (Minimal Luxury UI)
|
||||
|
||||
本文件定義了 Star Cloud 專案的核心視覺語言。所有新頁面與組件開發必須嚴格遵守此規範。
|
||||
|
||||
## 1. 核心設計令牌 (Design Tokens)
|
||||
|
||||
### 色彩系統 (CSS Variables)
|
||||
位於 `resources/css/app.css`:
|
||||
- `--color-luxury-deep`: `#0f172a` (深色背景)
|
||||
- `--color-luxury-card`: `#1e293b` (卡片背景)
|
||||
- `--color-accent`: `#06b6d4` (青色點綴,適用於按鈕與標示)
|
||||
|
||||
### 字體 (Typography)
|
||||
- **內文字體**: `Plus Jakarta Sans`
|
||||
- **標題/顯示字體**: `Outfit`
|
||||
- **特性**: 標題需帶有 `letter-spacing: -0.02em` 以增強精密感。
|
||||
|
||||
## 2. 核心組件樣式
|
||||
|
||||
### 豪華卡片 (Luxury Card)
|
||||
```html
|
||||
<div class="luxury-card p-6 rounded-2xl animate-luxury-in">
|
||||
</div>
|
||||
```
|
||||
- **特效**: 懸停時帶有 Y 軸平移與深度投影。
|
||||
|
||||
### 側邊導覽項 (Luxury Nav Item)
|
||||
```html
|
||||
<a href="#" class="luxury-nav-item active">
|
||||
<i class="lucide-icon"></i>
|
||||
<span>節點名稱</span>
|
||||
</a>
|
||||
```
|
||||
- **啟用狀態**: 左側帶有圓角直條指示器,並輔以青色發光陰影。
|
||||
|
||||
### 按鈕組件 (Buttons)
|
||||
- **Primary**: `.btn-luxury-primary` (青色漸層,適用於建立、儲存)
|
||||
- **Secondary**: `.btn-luxury-secondary` (白色/深色背景,帶邊框,適用於編輯、篩選)
|
||||
- **Ghost**: `.btn-luxury-ghost` (無背景,適用於取消、查看更多)
|
||||
|
||||
```html
|
||||
<button class="btn-luxury-primary">
|
||||
<i class="lucide-plus w-4 h-4"></i>
|
||||
<span>建立新機台</span>
|
||||
</button>
|
||||
|
||||
<button class="btn-luxury-ghost">取消</button>
|
||||
```
|
||||
|
||||
## 3. 動畫與互動
|
||||
|
||||
### 進場動畫
|
||||
- **`.animate-luxury-in`**: 所有的主內容區域或卡片在頁面載入時,應具備由下而上的淡入效果。
|
||||
|
||||
### 互動過渡 (Transitions)
|
||||
- **標準時間**: 所有的懸停、色彩變換等過渡效果,統一建議使用 **`duration-300`** (300ms)。
|
||||
- **例外**: 極其細微的透明度變化可縮短至 `150ms`,但涉及背景色與位移的互動一律以 `300ms` 為準。
|
||||
|
||||
### Alpine.js 互動模式 (以時間選擇器為例)
|
||||
- **互動原則**: 點擊觸發下拉選單時,必須使用 `x-transition` 且帶有 `scale` 偏移。
|
||||
- **樣式要求**: 選單背景需使用玻璃擬態 (Glassmorphism) 或帶透明度的深色背景。
|
||||
|
||||
## 4. 實作檢查清單 (Checklist)
|
||||
|
||||
- [x] **列表佈局**: 是否採用「整合式卡片」結構且內距設為 `p-8`?
|
||||
- [ ] **分頁與總數**: 列表底部是否正確召喚 `vendor.pagination.luxury`?
|
||||
- [ ] **刪除動作**: 是否已全面使用 `<x-delete-confirm-modal />` 封裝執行路徑?
|
||||
- [ ] **文字色階**: 符合標題 `slate-900/white` 與標籤 `slate-500` 的對比度。
|
||||
- [ ] **可讀性檢查**: 二級資訊是否達到 `text-xs` (12px) 且權重不超過 `font-bold`?
|
||||
|
||||
## 5. 開發注意事項 (Important Notes)
|
||||
|
||||
### 技術限制備忘
|
||||
- **CSS 編譯**: 複雜的 `box-shadow` 或漸層應直接寫原生 CSS 屬性,避免在 `@apply` 中使用帶空格的數值導致編譯失敗。
|
||||
- **深色模式**: 互動式按鈕在深色模式下必須強化文字亮度(`dark:text-white`),並輔以青色發光效果。
|
||||
|
||||
### 即時動態呈現規範
|
||||
- **格式**: `#機台編號 動作內容` (例如 `#V-001 執行出貨`)。
|
||||
- **脈絡**: 必須呈現相對時間與機台位置。
|
||||
|
||||
## 6. 頁面佈局規範 (Page Layout)
|
||||
|
||||
### 佈局決策規則 (Layout Decision Rules)
|
||||
|
||||
根據篩選條件的複雜程度,選擇適當的清單頁面佈局:
|
||||
|
||||
#### 1. 整合式佈局 (Integrated Layout) - 【預設推薦】
|
||||
- **適用場景**: 絕大多數 CRUD 列表。
|
||||
- **實作方式**: 篩選器、工具列與資料表格全部封裝在同一個 `luxury-card` 中。
|
||||
- **內距規範**: 強制使用 `p-8` 以獲得最佳空氣感。
|
||||
- **元件間距**: 篩選區與表格之間固定使用 `mb-10`。
|
||||
- **範例**: 帳號管理、角色設定、機台日誌。
|
||||
|
||||
#### 2. 分離式佈局 (Split Layout)
|
||||
- **適用場景**: 複雜查詢 (Filtered Fields >= 3 或多行篩選)。
|
||||
- **實作方式**: 篩選區獨立為一個 `luxury-card`,下方間隔 `mb-6` 後再放置資料清單卡片。
|
||||
- **樣式規範**: 篩選卡片通常使用 `p-6`(緊湊式),清單卡片使用 `p-8`(寬鬆式)。
|
||||
- **範例**: 交易紀錄、機台日誌。
|
||||
|
||||
### 標準寬版佈局 (Wide Layout)
|
||||
|
||||
```html
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Title') }}</h1>
|
||||
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Subtitle') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="btn-luxury-primary">
|
||||
<i class="lucide-plus w-4 h-4"></i>
|
||||
<span>新增</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
|
||||
<div class="flex items-center justify-between mb-10">
|
||||
<form class="relative group">
|
||||
<input type="text" class="luxury-input pl-12 pr-6 w-64" placeholder="{{ __('Search...') }}">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-separate border-spacing-y-0">
|
||||
<thead>
|
||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Name') }}</th>
|
||||
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Action') }}</th>
|
||||
</tr>
|
||||
</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">Example Name</td>
|
||||
<td class="px-6 py-6 text-right"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
|
||||
{{ $items->links('vendor.pagination.luxury') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
```
|
||||
|
||||
### 佈局核心原則:
|
||||
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` 以呈現高級設計感。
|
||||
|
||||
## 7. 表單元件規範 (Form Elements)
|
||||
|
||||
針對輸入框與下拉選單,強制使用以下類別以確保深色模式質感。
|
||||
|
||||
### 輸入框與選單
|
||||
- **類別**: `.luxury-input`, `.luxury-select`
|
||||
- **特性**:
|
||||
- 深色模式下具備半透明背景與背景模糊效果。
|
||||
- 統一的 `rounded-xl` 圓角與 `font-bold` 字體。
|
||||
- 聚焦時帶有青色 (`Cyan`) 發光邊框。
|
||||
|
||||
```html
|
||||
<input type="text" class="luxury-input" placeholder="請輸入內容">
|
||||
|
||||
<select class="luxury-select">
|
||||
<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)
|
||||
|
||||
為了讓分層資訊更具視覺引導,各個區塊 (Section) 的圖示應採用不同的顏色意象。
|
||||
|
||||
### 區塊圖示色彩意象 (Section Icon Palette)
|
||||
- **基本資訊 (Basic Info)**: **翠綠色 (`Emerald`)**。代表核心、穩定與起點。
|
||||
- 樣式: `bg-emerald-500/10 text-emerald-500`
|
||||
- **硬體/插槽設定**: **琥珀色 (`Amber/Orange`)**。代表動作、物理連接與硬體警告。
|
||||
- 樣式: `bg-amber-500/10 text-amber-500`
|
||||
- **系統/進階設定**: **靛藍色 (`Indigo`)**。代表邏輯、權限與深層配置。
|
||||
- 樣式: `bg-indigo-500/10 text-indigo-500`
|
||||
- **危險/移除動作**: **玫瑰紅 (`Rose`)**。代表破壞性操作。
|
||||
- 樣式: `bg-rose-500/10 text-rose-500`
|
||||
|
||||
## 9. 資料表格規範 (Data Tables)
|
||||
|
||||
為了確保管理後台資料的可讀性與精密感,表格內的所有文字級別必須對齊以下規範:
|
||||
|
||||
### 文字大小與權重 (Typography Hierarchy)
|
||||
- **表頭 (Table Header)**:
|
||||
- 類別: `text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em]`
|
||||
- 作用: 提供清晰的欄位定義而不奪取資料視覺焦點。具備足夠對比度。
|
||||
- **主標題 (Primary Item)**:
|
||||
- 類別: `text-base font-extrabold text-slate-800 dark:text-slate-100`
|
||||
- 範例: 公司名稱、機體名稱。
|
||||
- **次要資訊 (Secondary Info)**:
|
||||
- 類別: `text-xs font-bold text-slate-500 dark:text-slate-400 tracking-wide`
|
||||
- 範例: 使用者帳號、備註、權限名稱。
|
||||
- **狀態標籤 (Status Badge)**:
|
||||
- 範例: 啟用 (`emerald`)、禁用 (`rose`) / 角色名稱 (`sky`/`indigo`)。
|
||||
- 特性: `px-2.5 py-1 rounded-lg text-xs font-bold border tracking-wider`
|
||||
|
||||
### 空間與反應 (Spacing & Interaction)
|
||||
- **單元格內距**: 統一使用 `px-6 py-6`。
|
||||
- **懸停反應**: 必須在 `tr` 套用 `group` 且子<E4B894>### 9.4 標竿刪除確認模式 (Luxury Delete Modal Pattern)
|
||||
當執行刪除或具備破壞性的操作時,**禁止**使用瀏覽器原生 `confirm()` 或簡易的 `x-modal`。全站統一使用 **`<x-delete-confirm-modal />`** Blade 組件進行二次確認。
|
||||
|
||||
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`,保持直挺專業感。
|
||||
|
||||
```html
|
||||
<!-- 使用範例 -->
|
||||
<x-delete-confirm-modal :message="__('Are you sure you want to delete this account?')" />
|
||||
```
|
||||
|
||||
## 10. 系統兼容性與標準化 (Compatibility & Standardization)
|
||||
|
||||
為了確保在不同版本的開發環境中(如目前專案使用的 Tailwind CSS v3.1)UI 都能正確呈現,並維持全站操作感一致,必須遵守以下額外規範。
|
||||
|
||||
### Tailwind CSS 版本兼容性 (v3.1)
|
||||
- **禁止使用 `size-` 屬性**: 舊版不支援 `size-4` 等語法,請一律分拆寫作 `w-4 h-4`。
|
||||
- **避免非標準間距**: 避免使用 `4.5` (`18px`) 等任意值,優先使用標準等級如 `4` (`16px`) 或 `5` (`20px`)。
|
||||
|
||||
## 11. 字體與技術資訊規範 (Typography & Technical Data)
|
||||
|
||||
為了確保全站「次要資訊」具備極一致的高級感,必須遵守以下「機台標竿」規範:
|
||||
|
||||
### 核心樣式級別 (Core Typography Scale)
|
||||
| 資訊類型 | 客戶/配置名稱 (標題) | 技術代碼 (ID, SN, Code) | 清單時間 (Timestamps) | 分隔符號 (•) |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **字體族** | `font-sans` (Plus Jakarta Sans) | `font-mono` (微縮型單雙格) | `font-mono` (或 `sans` 視場景) | `font-sans` |
|
||||
| **尺寸** | `text-base` | `text-xs` (不可使用 10px) | `text-xs` | `text-xs` |
|
||||
| **字重** | `font-extrabold` (800) | `font-bold` (700) | `font-black` (900) | `font-bold` |
|
||||
| **字距** | `tracking-tight` (-0.02em) | `tracking-widest` (最寬) | `tracking-widest` | `tracking-normal` |
|
||||
| **格式** | 保持原始名稱 | `uppercase` (強制大寫) | `uppercase` | N/A |
|
||||
| **色彩** | `slate-900` / `slate-100` | `slate-500` / `slate-400` | `slate-400` / `slate-400/80` | `slate-300` / `slate-700` |
|
||||
|
||||
### 實作禁忌與準則
|
||||
- **禁止斜體 (No Italics)**: 名稱欄位嚴禁附帶 `italic`(特別是標題或配置名稱),保持直挺專業感。
|
||||
- **作用範圍 (Mono Scoping)**: `font-mono` 僅限作用於「純英文/數字」的代碼。Email 或分隔點必須回歸 `font-sans` 以確保圓潤。
|
||||
- **權重載入 (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-*` 系列組件是否滿足需求。**
|
||||
> 嚴禁在 Blade 中寫入大量重複的 `bg-indigo-600` 等舊式類別。
|
||||
|
||||
---
|
||||
> [!TIP]
|
||||
> 當遇到未定義的 UI 區塊時,優先參考 `admin.dashboard.blade.php` 的卡片與即時動態實作方式進行衍生。
|
||||
14
.env.example
14
.env.example
@@ -1,12 +1,14 @@
|
||||
APP_NAME=startCloud
|
||||
COMPOSE_PROJECT_NAME=start-cloud
|
||||
APP_NAME=starCloud
|
||||
COMPOSE_PROJECT_NAME=star-cloud
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8090
|
||||
APP_PORT=8090
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_LOCALE=zh_TW
|
||||
APP_TIMEZONE=Asia/Taipei
|
||||
DB_TIMEZONE="+08:00"
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
@@ -25,7 +27,7 @@ LOG_LEVEL=debug
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=start-cloud
|
||||
DB_DATABASE=star_cloud
|
||||
DB_USERNAME=sail
|
||||
DB_PASSWORD=password
|
||||
# FORWARD_DB_PORT=3308
|
||||
@@ -38,7 +40,7 @@ SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
@@ -49,7 +51,7 @@ REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
# FORWARD_REDIS_PORT=6380
|
||||
FORWARD_REDIS_PORT=6380
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_SCHEME=null
|
||||
|
||||
116
.gitea/workflows/deploy-demo.yaml
Normal file
116
.gitea/workflows/deploy-demo.yaml
Normal file
@@ -0,0 +1,116 @@
|
||||
name: star-cloud-deploy-demo
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- demo
|
||||
|
||||
jobs:
|
||||
deploy-demo:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
github-server-url: https://gitea.taiwan-star.com.tw
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- name: Step 1 - Push Code to Demo
|
||||
run: |
|
||||
apt-get update && apt-get install -y rsync openssh-client
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEMO_SSH_KEY }}" > ~/.ssh/id_rsa_demo
|
||||
chmod 600 ~/.ssh/id_rsa_demo
|
||||
rsync -avz --delete \
|
||||
--exclude='/.git' \
|
||||
--exclude='/node_modules' \
|
||||
--exclude='/vendor' \
|
||||
--exclude='/storage' \
|
||||
--exclude='/.env' \
|
||||
--exclude='/public/build' \
|
||||
-e "ssh -p 2227 -i ~/.ssh/id_rsa_demo -o StrictHostKeyChecking=no" \
|
||||
./ root@220.132.7.82:/var/www/star-cloud-demo/
|
||||
rm ~/.ssh/id_rsa_demo
|
||||
|
||||
- name: Step 2 - Check if Rebuild Needed
|
||||
id: check_rebuild
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2227
|
||||
username: root
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
cd /var/www/star-cloud-demo
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.demo\.yaml|docker-compose\.yaml)'; then
|
||||
echo "REBUILD_NEEDED=true"
|
||||
else
|
||||
echo "REBUILD_NEEDED=false"
|
||||
fi
|
||||
|
||||
- name: Step 3 - Container Up & Health Check
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2227
|
||||
username: root
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
cd /var/www/star-cloud-demo
|
||||
chown -R 1000:1000 .
|
||||
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.demo\.yaml|docker-compose\.yaml)'; then
|
||||
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --build --wait
|
||||
else
|
||||
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
||||
if ! docker ps --format '{{.Names}}' | grep -q 'star-cloud-demo-laravel'; then
|
||||
echo "容器未運行,正在啟動..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --wait
|
||||
else
|
||||
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "容器狀態:" && docker ps --filter "name=star-cloud-demo"
|
||||
|
||||
- name: Step 4 - Composer & NPM Build
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2227
|
||||
username: root
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
docker exec -u 1000:1000 -w /var/www/html star-cloud-demo-laravel sh -c "
|
||||
# 1. 後端依賴
|
||||
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||
|
||||
# 2. 前端編譯
|
||||
npm install &&
|
||||
npm run build &&
|
||||
|
||||
# 3. Laravel 初始化與優化
|
||||
php artisan migrate --force &&
|
||||
php artisan storage:link &&
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
php artisan view:cache &&
|
||||
php artisan db:seed --class=RoleSeeder --force &&
|
||||
php artisan db:seed --class=AdminUserSeeder --force
|
||||
"
|
||||
docker exec star-cloud-demo-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
|
||||
- name: Step 5 - Auto Sync workflows to main
|
||||
run: |
|
||||
git config --global user.email "bot@taiwan-star.com.tw"
|
||||
git config --global user.name "CICD Bot"
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
git checkout ${{ github.ref_name }} -- .gitea/workflows/
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "[AUTO] Sync workflows from ${{ github.ref_name }} to main"
|
||||
GIT_SSH_COMMAND="ssh -p 3222 -o StrictHostKeyChecking=no" git push origin main
|
||||
fi
|
||||
git checkout ${{ github.ref_name }}
|
||||
|
||||
21
.gitea/workflows/deploy-prod.yaml
Normal file
21
.gitea/workflows/deploy-prod.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
name: star-cloud-deploy-production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
github-server-url: https://gitea.taiwan-star.com.tw
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- name: Step 1 - Push Code to Production
|
||||
run: |
|
||||
echo "Production deployment is currently in preparation..."
|
||||
# 待正式環境資料確定後,再補上 rsync 與 SSH 邏輯
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -17,3 +17,7 @@ yarn-error.log
|
||||
/.fleet
|
||||
/.idea
|
||||
/.vscode
|
||||
/docs/API
|
||||
/docs/*.xlsx
|
||||
|
||||
|
||||
|
||||
216
README.md
216
README.md
@@ -1,112 +1,116 @@
|
||||
# Star Cloud 智能販賣機管理平台
|
||||
|
||||
## 專案簡介 (Project Description)
|
||||
Star Cloud 是一個專為智能販賣機設計的後台管理系統,旨在提供全方位的機台監控、庫存管理、銷售分析與會員管理功能。透過此平台,管理者可以即時掌握機台運營狀態,優化補貨流程,並透過數據分析提升營運效益。
|
||||
> 基於 Docker 的全方位智能販賣機後台管理系統 (Cloud 平台)
|
||||
|
||||
## 技術棧 (Technology Stack)
|
||||
|
||||
### 後端 (Backend)
|
||||
- **Framework**: Laravel 10.x
|
||||
- **Language**: PHP 8.1+
|
||||
- **Database**: MySQL 8.0+
|
||||
- **Authentication**: Laravel Sanctum (API Token Authentication)
|
||||
- **Tools**: Composer
|
||||
|
||||
### 前端 (Frontend)
|
||||
- **Framework**: Blade Templates (Laravel 預設樣板引擎)
|
||||
- **CSS Framework**: Tailwind CSS 3.x
|
||||
- **JavaScript**: Alpine.js 3.x
|
||||
- **Build Tool**: Vite 5.x
|
||||
- **HTTP Client**: Axios
|
||||
|
||||
## 安裝與使用說明 (Installation & Usage)
|
||||
|
||||
請依照以下步驟將專案 Clone 至本地端並開始運行:
|
||||
|
||||
### 0. 前置需求 (Prerequisites)
|
||||
確保您的系統已安裝以下軟體:
|
||||
- PHP 8.1+
|
||||
- Composer
|
||||
- Node.js & npm
|
||||
- MySQL 8.0+
|
||||
|
||||
若您尚未安裝 MySQL,Windows 使用者可至 [MySQL 官網](https://dev.mysql.com/downloads/installer/) 下載 Installer,或使用 XAMPP / Laragon 等整合環境。
|
||||
|
||||
### 1. 下載專案 (Clone Repository)
|
||||
```bash
|
||||
git clone <repository_url>
|
||||
cd star-cloud
|
||||
```
|
||||
|
||||
### 2. 安裝依賴套件 (Install Dependencies)
|
||||
|
||||
安裝後端 PHP 套件:
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
|
||||
安裝前端 Node.js 套件:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. 環境變數設定 (Environment Setup)
|
||||
複製範例環境設定檔:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
請開啟 `.env` 檔案,並依照您的本地環境設定資料庫連線資訊:
|
||||
```dotenv
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=star_cloud
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=your_password
|
||||
```
|
||||
|
||||
產生應用程式金鑰 (Application Key):
|
||||
```bash
|
||||
php artisan key:generate
|
||||
```
|
||||
|
||||
### 4. 資料庫遷移 (Database Migration)
|
||||
執行 Migration 以建立資料庫結構:
|
||||
```bash
|
||||
php artisan migrate
|
||||
```
|
||||
php artisan migrate --seed
|
||||
```
|
||||
|
||||
### 4.1 預設管理員帳號 (Default Admin Account)
|
||||
執行上述指令後,系統會建立一組預設管理員帳號:
|
||||
- **Email**: `admin@star-cloud.com`
|
||||
- **Password**: `password`
|
||||
|
||||
### 5. 編譯前端資源 (Build Frontend Assets)
|
||||
啟動開發模式 (Hot Module Replacement):
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
或編譯生產環境檔案:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 6. 啟動伺服器 (Start Server)
|
||||
啟動 Laravel 開發伺服器:
|
||||
```bash
|
||||
php artisan serve --port=8001
|
||||
```
|
||||
預設網址為:http://localhost:8001
|
||||
|
||||
## 主要功能模組
|
||||
- **儀錶板 (Dashboard)**: 銷售數據概覽、機台狀態監控
|
||||
- **機台管理 (Machine Management)**: 機台列表、遠端控制、日誌查詢
|
||||
- **商品與庫存 (Inventory)**: 商品管理、進銷存、補貨單
|
||||
- **銷售管理 (Sales)**: 交易紀錄、營收報表
|
||||
- **權限設定 (Permissions)**: 角色與權限分配
|
||||
Star Cloud 是一個專為智能販賣機設計的後台管理系統,負責管理機台、商品、銷售數據,並為硬體端點提供專用的 API。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 技術架構
|
||||
|
||||
### 核心架構
|
||||
本專案採用 **傳統單體式架構 (Monolithic Architecture)**,結合 Laravel Blade 引擎進行伺服器端渲染 (SSR)。
|
||||
|
||||
| 服務 | 容器名稱 | 技術 | 用途 | 本地 Port |
|
||||
|------|---------|------|------|--------|
|
||||
| **應用程式** | `star-cloud-laravel` | Laravel 12 + PHP 8.5 | 核心業務與渲染 | 8090 |
|
||||
| **資料庫** | `star-cloud-mysql` | MySQL 8.0 | 數據持久化 | 3306 |
|
||||
| **快取/隊列** | `star-cloud-redis` | Redis Alpine | IoT 高併發隊列 | 6380 |
|
||||
|
||||
### 後端技術棧
|
||||
- **Framework**: Laravel 12.x
|
||||
- **Language**: PHP 8.5
|
||||
- **Redis**: 用於 IoT 高併發隊列 (B010, B600 等)
|
||||
- **Database**: MySQL 8.0
|
||||
|
||||
### 前端技術棧
|
||||
- **View**: Laravel Blade
|
||||
- **CSS**: Tailwind CSS + **Preline UI**
|
||||
- **JS**: Alpine.js (行為控制)
|
||||
- **Build**: Vite
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 開發環境 (Laravel Sail)
|
||||
|
||||
本專案建議使用 **Laravel Sail** 進行開發,避免直接在宿主機執行指令。
|
||||
|
||||
### 前置需求
|
||||
- Docker Desktop (Windows/Mac) 或 Docker Engine (Linux)
|
||||
- Git
|
||||
|
||||
### 快速啟動
|
||||
1. `clone` 專案並進入目錄
|
||||
2. `cp .env.example .env`
|
||||
3. `./vendor/bin/sail up -d`
|
||||
4. `./vendor/bin/sail composer install`
|
||||
5. `./vendor/bin/sail artisan key:generate`
|
||||
6. `./vendor/bin/sail artisan migrate --seed`
|
||||
7. `./vendor/bin/sail npm install`
|
||||
8. `./vendor/bin/sail npm run dev`
|
||||
|
||||
### 常用開發指令
|
||||
| 功能 | 指令 |
|
||||
|------|------|
|
||||
| 啟動環境 | `./vendor/bin/sail up -d` |
|
||||
| 停止環境 | `./vendor/bin/sail down` |
|
||||
| Artisan 指令 | `./vendor/bin/sail artisan <cmd>` |
|
||||
| Composer 指令 | `./vendor/bin/sail composer <cmd>` |
|
||||
| NPM 指令 | `./vendor/bin/sail npm <cmd>` |
|
||||
| 執行測試 | `./vendor/bin/sail test` |
|
||||
|
||||
---
|
||||
|
||||
## 🔐 API 規範
|
||||
|
||||
系統 API 分為兩大類,遵循不同的設計慣例:
|
||||
|
||||
### 1. Admin/Web API (`/api/v1/...`)
|
||||
- **對象**: 後台管理介面、APP UI。
|
||||
- **認證**: Laravel Sanctum (Session/Token)。
|
||||
- **格式**: 標準 RESTful JSON。
|
||||
|
||||
### 2. Machine IoT API (`/api/app/...`)
|
||||
- **對象**: 智能販賣機、計時器等硬體。
|
||||
- **認證**: Header `Authorization: Bearer <api_token>`。
|
||||
- **高併發處理**: 核心日誌 (B010 心跳、B600 交易) **嚴禁直寫 DB**,必須進入 **Redis Queue** 背景異步處理。
|
||||
|
||||
---
|
||||
|
||||
## 🌐 多語系支援 (I18n)
|
||||
|
||||
所有 UI 顯示文字必須支援多語系,禁止 Hard-coded。
|
||||
- **語系檔案**: `lang/zh_TW.json`, `lang/en.json`, `lang/ja.json`。
|
||||
- **呼叫方式**: 使用 `__('Phrases in English')` 或 `@lang('...')`。
|
||||
- **命名規範**: 優先使用「英文原始詞彙」作為 Key 名稱。
|
||||
|
||||
---
|
||||
|
||||
## 📂 目錄結構
|
||||
|
||||
- `app/Http/Controllers/`: 控制器
|
||||
- `app/Models/{Domain}/`: 分領域的模型 (如 `Machine`, `Member`)
|
||||
- `app/Services/{Domain}/`: 封裝商業邏輯與資料異動
|
||||
- `app/Jobs/{Domain}/`: IoT 異步隊列處理任務 (重要)
|
||||
- `resources/views/`: Blade 模板 (按功能分資料夾)
|
||||
- `resources/views/components/`: 可重用的 UI 組件
|
||||
- `routes/`: 路由定義 (`web.php` 與 `api.php`)
|
||||
|
||||
---
|
||||
|
||||
## 🚢 CI/CD 與部署
|
||||
|
||||
- **自動化工具**: Gitea Actions (`.gitea/workflows/`)。
|
||||
- **Demo 環境**: 推送到 `demo` 分支會自動部署至 `demo-cloud.taiwan-star.com.tw`。
|
||||
- **伺服器路徑**: `/var/www/star-cloud-demo` (透過 `ssh gitea_work` 登入)。
|
||||
|
||||
---
|
||||
|
||||
## 授權與版權
|
||||
© Star Cloud. All Rights Reserved.
|
||||
|
||||
---
|
||||
|
||||
## 技術支援
|
||||
|
||||
如有問題或建議,請聯繫開發團隊或提交 Issue。
|
||||
64
app/Console/Commands/Machine/SimulateMachineLogs.php
Normal file
64
app/Console/Commands/Machine/SimulateMachineLogs.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Machine;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SimulateMachineLogs extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'simulate:machine-logs {--count=10 : 發送日誌的次數}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '模擬機台發送 API 日誌請求到後端 (用於壓測與驗證 Queue)';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$count = (int) $this->option('count');
|
||||
$machines = Machine::all();
|
||||
|
||||
if ($machines->isEmpty()) {
|
||||
$this->error('No machines found. Please run MachineSeeder first.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info("Starting simulation of {$count} logs...");
|
||||
|
||||
$bar = $this->output->createProgressBar($count);
|
||||
$bar->start();
|
||||
|
||||
// 由於是在同一個開發環境,且在 Sail 容器內部執行,
|
||||
// 外部 8090 埠對應容器內部 8080 埠。
|
||||
$baseUrl = 'http://localhost:8080/api/v1/machines/';
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$machine = $machines->random();
|
||||
$level = collect(['info', 'warning', 'error'])->random();
|
||||
|
||||
try {
|
||||
Http::post($baseUrl . $machine->id . '/logs', [
|
||||
'level' => $level,
|
||||
'message' => "Simulated message #{$i} for machine {$machine->name}",
|
||||
'context' => [
|
||||
'simulated' => true,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
]
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->error("\nFailed to send log: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info('Simulation completed.');
|
||||
}
|
||||
}
|
||||
10
app/Http/Controllers/Admin/AdminController.php
Normal file
10
app/Http/Controllers/Admin/AdminController.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
abstract class AdminController extends Controller
|
||||
{
|
||||
// Admin 相關的共用邏輯可寫於此
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AppConfig;
|
||||
use App\Models\System\AppConfig;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AppConfigController extends Controller
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\BasicSettings;
|
||||
|
||||
use App\Http\Controllers\Admin\AdminController;
|
||||
use App\Models\Machine\MachineModel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class MachineModelController extends AdminController
|
||||
{
|
||||
/**
|
||||
* 顯示機台型號列表 (重新導向至機台設定的標籤頁)
|
||||
*/
|
||||
public function index(Request $request): RedirectResponse
|
||||
{
|
||||
return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models']);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
MachineModel::create(array_merge($validated, [
|
||||
'company_id' => auth()->user()->company_id,
|
||||
'creator_id' => auth()->id(),
|
||||
'updater_id' => auth()->id(),
|
||||
]));
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models'])
|
||||
->with('success', __('Machine model created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示編輯頁面 (與 index 共用 Modal 則不需此方法,但 resource 路由建議保留或調整)
|
||||
*/
|
||||
public function edit(MachineModel $machine_model): View
|
||||
{
|
||||
// 若採用 index Modal 編輯,此處可回傳 JSON 或維持 Blade
|
||||
return view('admin.basic-settings.machine-models.edit', compact('machine_model'));
|
||||
}
|
||||
|
||||
public function update(Request $request, MachineModel $machine_model): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$machine_model->update(array_merge($validated, [
|
||||
'updater_id' => auth()->id(),
|
||||
]));
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models'])
|
||||
->with('success', __('Machine model updated successfully.'));
|
||||
}
|
||||
|
||||
public function destroy(MachineModel $machine_model): RedirectResponse
|
||||
{
|
||||
if ($machine_model->machines()->count() > 0) {
|
||||
return redirect()->back()->with('error', __('Cannot delete model that is currently in use by machines.'));
|
||||
}
|
||||
|
||||
$machine_model->delete();
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models'])
|
||||
->with('success', __('Machine model deleted successfully.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MachinePhotoController extends Controller
|
||||
{
|
||||
use ImageHandler;
|
||||
|
||||
/**
|
||||
* 更新機台照片
|
||||
*/
|
||||
public function update(Request $request, Machine $machine): RedirectResponse
|
||||
{
|
||||
Log::info('Machine Photo Update Request', [
|
||||
'machine_id' => $machine->id,
|
||||
'files' => $request->allFiles()
|
||||
]);
|
||||
|
||||
try {
|
||||
$images = $machine->images ?? [];
|
||||
|
||||
// 處理 3 個索引位置的圖片
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
// 先處理刪除標記
|
||||
if ($request->input("delete_photo_{$i}") === '1') {
|
||||
if (isset($images[$i])) {
|
||||
unset($images[$i]);
|
||||
}
|
||||
}
|
||||
|
||||
// 再處理檔案上傳(若有上傳會覆蓋掉刪除邏輯或原有的圖)
|
||||
$fieldName = "machine_image_{$i}";
|
||||
if ($request->hasFile($fieldName)) {
|
||||
$file = $request->file($fieldName);
|
||||
|
||||
// 轉為 WebP 格式與保存
|
||||
$path = $this->storeAsWebp($file, "machines/{$machine->id}");
|
||||
$images[$i] = $path;
|
||||
|
||||
Log::info("Machine image uploaded at slot {$i}", ['path' => $path]);
|
||||
}
|
||||
}
|
||||
|
||||
// 過濾掉 null 並重新整理索引,但這裡我們希望保持 3 個槽位的概念
|
||||
// 如果用戶想保持順序,我們就直接儲存
|
||||
ksort($images);
|
||||
|
||||
$machine->update([
|
||||
'images' => $images,
|
||||
'updater_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Machine images updated successfully.'));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Machine Photo Update Failed', [
|
||||
'machine_id' => $machine->id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return back()->with('error', __('Failed to update machine images: ') . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\BasicSettings;
|
||||
|
||||
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;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
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);
|
||||
$search = $request->input('search');
|
||||
|
||||
// 1. 處理機台清單 (Machines Tab)
|
||||
$machineQuery = Machine::query()->with(['machineModel', 'paymentConfig', 'company']);
|
||||
if ($tab === 'machines' && $search) {
|
||||
$machineQuery->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
$machines = $machineQuery->latest()->paginate($per_page, ['*'], 'machines_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();
|
||||
|
||||
// 3. 基礎下拉資料 (用於新增/編輯機台的彈窗)
|
||||
$models = MachineModel::select('id', 'name')->get();
|
||||
$paymentConfigs = PaymentConfig::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',
|
||||
'models',
|
||||
'paymentConfigs',
|
||||
'companies',
|
||||
'tab'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存新機台 (僅核心欄位)
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'serial_no' => 'required|string|unique:machines,serial_no',
|
||||
'company_id' => 'required|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',
|
||||
]);
|
||||
|
||||
$imagePaths = [];
|
||||
if ($request->hasFile('images')) {
|
||||
foreach (array_slice($request->file('images'), 0, 3) as $image) {
|
||||
$imagePaths[] = $this->storeAsWebp($image, 'machines');
|
||||
}
|
||||
}
|
||||
|
||||
$machine = Machine::create(array_merge($validated, [
|
||||
'status' => 'offline',
|
||||
'creator_id' => auth()->id(),
|
||||
'updater_id' => auth()->id(),
|
||||
'card_reader_seconds' => 30, // 預設值
|
||||
'card_reader_checkout_time_1' => '22:30:00',
|
||||
'card_reader_checkout_time_2' => '23:45:00',
|
||||
'payment_buffer_seconds' => 5,
|
||||
'images' => $imagePaths,
|
||||
]));
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index')
|
||||
->with('success', __('Machine created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示詳細編輯頁面
|
||||
*/
|
||||
public function edit(Machine $machine): View
|
||||
{
|
||||
$models = MachineModel::select('id', 'name')->get();
|
||||
$paymentConfigs = PaymentConfig::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'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新機台詳細參數
|
||||
*/
|
||||
public function update(Request $request, Machine $machine): RedirectResponse
|
||||
{
|
||||
Log::info('Machine Update Request', ['machine_id' => $machine->id, 'data' => $request->all()]);
|
||||
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'card_reader_seconds' => 'required|integer|min:0',
|
||||
'payment_buffer_seconds' => 'required|integer|min:0',
|
||||
'card_reader_checkout_time_1' => 'nullable|string',
|
||||
'card_reader_checkout_time_2' => 'nullable|string',
|
||||
'heating_start_time' => 'nullable|string',
|
||||
'heating_end_time' => 'nullable|string',
|
||||
'card_reader_no' => 'nullable|string|max:255',
|
||||
'key_no' => 'nullable|string|max:255',
|
||||
'invoice_status' => 'required|integer|in:0,1,2',
|
||||
'welcome_gift_enabled' => 'boolean',
|
||||
'is_spring_slot_1_10' => 'boolean',
|
||||
'is_spring_slot_11_20' => 'boolean',
|
||||
'is_spring_slot_21_30' => 'boolean',
|
||||
'is_spring_slot_31_40' => 'boolean',
|
||||
'is_spring_slot_41_50' => 'boolean',
|
||||
'is_spring_slot_51_60' => 'boolean',
|
||||
'member_system_enabled' => 'boolean',
|
||||
'machine_model_id' => 'required|exists:machine_models,id',
|
||||
'payment_config_id' => 'nullable|exists:payment_configs,id',
|
||||
'location' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
// 僅限系統管理員可修改公司
|
||||
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) {
|
||||
Log::error('Machine Update Validation Failed', ['errors' => $e->errors()]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$machine->update(array_merge($validated, [
|
||||
'updater_id' => auth()->id(),
|
||||
]));
|
||||
|
||||
// 處理圖片更新 (支援 3 個獨立槽位)
|
||||
if ($request->hasFile('images')) {
|
||||
$currentImages = $machine->images ?? [];
|
||||
$newImages = $request->file('images');
|
||||
$updated = false;
|
||||
|
||||
foreach ($newImages as $index => $file) {
|
||||
// 限制 3 個槽位 (0, 1, 2)
|
||||
if ($index < 0 || $index > 2) continue;
|
||||
|
||||
// 刪除該槽位的舊圖
|
||||
if (isset($currentImages[$index]) && !empty($currentImages[$index])) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$index]);
|
||||
}
|
||||
|
||||
// 處理並儲存新圖
|
||||
$currentImages[$index] = $this->storeAsWebp($file, 'machines');
|
||||
$updated = true;
|
||||
}
|
||||
|
||||
if ($updated) {
|
||||
ksort($currentImages);
|
||||
$machine->update(['images' => array_values($currentImages)]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index')
|
||||
->with('success', __('Machine settings updated successfully.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\BasicSettings;
|
||||
|
||||
use App\Http\Controllers\Admin\AdminController;
|
||||
use App\Models\System\PaymentConfig;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class PaymentConfigController extends AdminController
|
||||
{
|
||||
/**
|
||||
* 顯示金流配置列表
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$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)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.basic-settings.payment-configs.index', [
|
||||
'paymentConfigs' => $configs
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示新增頁面
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
|
||||
return view('admin.basic-settings.payment-configs.create', compact('companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存金流配置
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'company_id' => 'required|exists:companies,id',
|
||||
'settings' => 'required|array',
|
||||
]);
|
||||
|
||||
PaymentConfig::create([
|
||||
'name' => $request->name,
|
||||
'company_id' => $request->company_id,
|
||||
'settings' => $request->settings,
|
||||
'creator_id' => auth()->id(),
|
||||
'updater_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.basic-settings.payment-configs.index')
|
||||
->with('success', __('Payment Configuration created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示編輯頁面
|
||||
*/
|
||||
public function edit(PaymentConfig $paymentConfig): View
|
||||
{
|
||||
$companies = \App\Models\System\Company::select('id', 'name')->get();
|
||||
return view('admin.basic-settings.payment-configs.edit', compact('paymentConfig', 'companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新金流配置
|
||||
*/
|
||||
public function update(Request $request, PaymentConfig $paymentConfig): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'settings' => 'required|array',
|
||||
]);
|
||||
|
||||
$paymentConfig->update([
|
||||
'name' => $request->name,
|
||||
'settings' => $request->settings,
|
||||
'updater_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.basic-settings.payment-configs.index')
|
||||
->with('success', __('Payment Configuration updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除金流配置
|
||||
*/
|
||||
public function destroy(PaymentConfig $paymentConfig): RedirectResponse
|
||||
{
|
||||
$paymentConfig->delete();
|
||||
return redirect()->route('admin.basic-settings.payment-configs.index')
|
||||
->with('success', __('Payment Configuration deleted successfully.'));
|
||||
}
|
||||
}
|
||||
153
app/Http/Controllers/Admin/CompanyController.php
Normal file
153
app/Http/Controllers/Admin/CompanyController.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\System\Company;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CompanyController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Company::query()->withCount(['users', 'machines']);
|
||||
|
||||
// 搜尋
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 狀態篩選
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$companies = $query->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
// 取得可供選擇的客戶角色範本 (系統層級的角色,排除 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'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'required|string|max:50|unique:companies,code',
|
||||
'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',
|
||||
'status' => 'required|boolean',
|
||||
'note' => 'nullable|string',
|
||||
// 帳號相關欄位 (可選)
|
||||
'admin_username' => 'nullable|string|max:255|unique:users,username',
|
||||
'admin_password' => 'nullable|string|min:8',
|
||||
'admin_name' => 'nullable|string|max:255',
|
||||
'admin_role' => 'nullable|string|exists:roles,name',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($validated) {
|
||||
$company = Company::create([
|
||||
'name' => $validated['name'],
|
||||
'code' => $validated['code'],
|
||||
'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,
|
||||
'status' => $validated['status'],
|
||||
'note' => $validated['note'] ?? null,
|
||||
]);
|
||||
|
||||
// 如果有填寫帳號資訊,則建立管理員帳號
|
||||
if (!empty($validated['admin_username']) && !empty($validated['admin_password'])) {
|
||||
$user = \App\Models\System\User::create([
|
||||
'company_id' => $company->id,
|
||||
'username' => $validated['admin_username'],
|
||||
'password' => \Illuminate\Support\Facades\Hash::make($validated['admin_password']),
|
||||
'name' => $validated['admin_name'] ?: ($validated['contact_name'] ?: $validated['name']),
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
// 角色初始化與克隆邏輯 (優先使用選擇的角色,否則使用預設)
|
||||
$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('name', '!=', 'super-admin')
|
||||
->first();
|
||||
|
||||
if ($template_role) {
|
||||
// 克隆範本為該公司的「管理員」
|
||||
$role_to_assign = \App\Models\System\Role::query()->create([
|
||||
'name' => '管理員',
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $company->id,
|
||||
'is_system' => false,
|
||||
]);
|
||||
$role_to_assign->syncPermissions($template_role->getPermissionNames());
|
||||
} else {
|
||||
// 如果找不到選定的角色範本,退而求其次嘗試指派現有角色 (通常不應發生)
|
||||
$role_to_assign = $selected_role_name;
|
||||
}
|
||||
|
||||
$user->assignRole($role_to_assign);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', __('Customer created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, Company $company)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'required|string|max:50|unique:companies,code,' . $company->id,
|
||||
'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',
|
||||
'status' => 'required|boolean',
|
||||
'note' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$company->update($validated);
|
||||
|
||||
return redirect()->back()->with('success', __('Customer updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Company $company)
|
||||
{
|
||||
if ($company->users()->count() > 0) {
|
||||
return redirect()->back()->with('error', __('Cannot delete company with active accounts.'));
|
||||
}
|
||||
|
||||
$company->delete();
|
||||
|
||||
return redirect()->back()->with('success', __('Customer deleted successfully.'));
|
||||
}
|
||||
}
|
||||
@@ -3,25 +3,40 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Machine;
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
// 模擬數據或從資料庫獲取
|
||||
// 由於目前沒有數據,我們先傳遞一些預設值或空集合
|
||||
$totalMachines = Machine::count();
|
||||
$onlineMachines = Machine::where('status', 'online')->count();
|
||||
$offlineMachines = Machine::where('status', 'offline')->count();
|
||||
$errorMachines = Machine::where('status', 'error')->count();
|
||||
// 每頁顯示筆數限制 (預設為 10)
|
||||
$perPage = (int) request()->input('per_page', 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();
|
||||
$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()
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.dashboard', compact(
|
||||
'totalMachines',
|
||||
'onlineMachines',
|
||||
'offlineMachines',
|
||||
'errorMachines'
|
||||
'totalRevenue',
|
||||
'activeMachines',
|
||||
'alertsPending',
|
||||
'memberCount',
|
||||
'machines'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,19 +29,11 @@ class DataConfigController extends Controller
|
||||
public function adminProducts()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '管理者可賣商品',
|
||||
'title' => '商品狀態',
|
||||
'description' => '管理者商品銷售權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 帳號管理
|
||||
public function accounts()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '帳號管理',
|
||||
'description' => '主帳號管理',
|
||||
]);
|
||||
}
|
||||
|
||||
// 子帳號管理
|
||||
public function subAccounts()
|
||||
|
||||
56
app/Http/Controllers/Admin/DepositBonusRuleController.php
Normal file
56
app/Http/Controllers/Admin/DepositBonusRuleController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Member\DepositBonusRule;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DepositBonusRuleController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$rules = DepositBonusRule::orderBy('min_amount')->get();
|
||||
return view('admin.deposit-bonus-rules.index', compact('rules'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'min_amount' => 'required|numeric|min:0',
|
||||
'bonus_type' => 'required|in:fixed,percentage',
|
||||
'bonus_value' => 'required|numeric|min:0',
|
||||
'is_active' => 'boolean',
|
||||
'start_at' => 'nullable|date',
|
||||
'end_at' => 'nullable|date|after:start_at',
|
||||
]);
|
||||
|
||||
DepositBonusRule::create($validated);
|
||||
|
||||
return redirect()->route('admin.deposit-bonus-rules.index')->with('success', '儲值回饋規則已建立');
|
||||
}
|
||||
|
||||
public function update(Request $request, DepositBonusRule $depositBonusRule)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'min_amount' => 'required|numeric|min:0',
|
||||
'bonus_type' => 'required|in:fixed,percentage',
|
||||
'bonus_value' => 'required|numeric|min:0',
|
||||
'is_active' => 'boolean',
|
||||
'start_at' => 'nullable|date',
|
||||
'end_at' => 'nullable|date|after:start_at',
|
||||
]);
|
||||
|
||||
$depositBonusRule->update($validated);
|
||||
|
||||
return redirect()->route('admin.deposit-bonus-rules.index')->with('success', '儲值回饋規則已更新');
|
||||
}
|
||||
|
||||
public function destroy(DepositBonusRule $depositBonusRule)
|
||||
{
|
||||
$depositBonusRule->delete();
|
||||
return redirect()->route('admin.deposit-bonus-rules.index')->with('success', '儲值回饋規則已刪除');
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Admin/GiftDefinitionController.php
Normal file
58
app/Http/Controllers/Admin/GiftDefinitionController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Member\GiftDefinition;
|
||||
use App\Models\Member\MembershipTier;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GiftDefinitionController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$gifts = GiftDefinition::with('tier')->get();
|
||||
$tiers = MembershipTier::orderBy('sort_order')->get();
|
||||
return view('admin.gift-definitions.index', compact('gifts', 'tiers'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|in:points,coupon,product,discount,cash',
|
||||
'value' => 'required|numeric|min:0',
|
||||
'tier_id' => 'nullable|exists:membership_tiers,id',
|
||||
'trigger' => 'required|in:register,birthday,annual,upgrade,manual',
|
||||
'validity_days' => 'required|integer|min:1',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
GiftDefinition::create($validated);
|
||||
|
||||
return redirect()->route('admin.gift-definitions.index')->with('success', '禮品已建立');
|
||||
}
|
||||
|
||||
public function update(Request $request, GiftDefinition $giftDefinition)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|in:points,coupon,product,discount,cash',
|
||||
'value' => 'required|numeric|min:0',
|
||||
'tier_id' => 'nullable|exists:membership_tiers,id',
|
||||
'trigger' => 'required|in:register,birthday,annual,upgrade,manual',
|
||||
'validity_days' => 'required|integer|min:1',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$giftDefinition->update($validated);
|
||||
|
||||
return redirect()->route('admin.gift-definitions.index')->with('success', '禮品已更新');
|
||||
}
|
||||
|
||||
public function destroy(GiftDefinition $giftDefinition)
|
||||
{
|
||||
$giftDefinition->delete();
|
||||
return redirect()->route('admin.gift-definitions.index')->with('success', '禮品已刪除');
|
||||
}
|
||||
}
|
||||
@@ -2,142 +2,263 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Machine;
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MachineController extends Controller
|
||||
class MachineController extends AdminController
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
* 顯示所有機台列表或效期管理
|
||||
*/
|
||||
public function index()
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$machines = Machine::latest()->paginate(10);
|
||||
return view('admin.machines.index', compact('machines'));
|
||||
$tab = $request->input('tab', 'list');
|
||||
$per_page = $tab === 'list' ? $request->input('per_page', 10) : $request->input('per_page', 12);
|
||||
|
||||
$query = Machine::query();
|
||||
|
||||
// 搜尋:名稱或序號
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($tab === 'list') {
|
||||
$machines = $query->when($request->status, function ($query, $status) {
|
||||
return $query->where('status', $status);
|
||||
})
|
||||
->latest()
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.machines.index', compact('machines', 'tab'));
|
||||
} else {
|
||||
// 效期管理模式:獲取機台及其貨道統計
|
||||
$machines = $query->withCount(['slots as total_slots'])
|
||||
->withCount(['slots as expired_count' => function ($q) {
|
||||
$q->where('expiry_date', '<', now()->toDateString());
|
||||
}])
|
||||
->withCount(['slots as pending_count' => function ($q) {
|
||||
$q->whereNull('expiry_date');
|
||||
}])
|
||||
->withCount(['slots as warning_count' => function ($q) {
|
||||
$q->whereBetween('expiry_date', [now()->toDateString(), now()->addDays(7)->toDateString()]);
|
||||
}])
|
||||
->latest()
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.machines.index', compact('machines', 'tab'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
* 顯示特定機台的日誌與詳細資訊
|
||||
*/
|
||||
public function create()
|
||||
public function show(int $id): View
|
||||
{
|
||||
return view('admin.machines.create');
|
||||
}
|
||||
$machine = Machine::with(['logs' => function ($query) {
|
||||
$query->latest()->limit(50);
|
||||
}])->findOrFail($id);
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'status' => 'required|in:online,offline,error',
|
||||
'temperature' => 'nullable|numeric',
|
||||
'firmware_version' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
Machine::create($validated);
|
||||
|
||||
return redirect()->route('admin.machines.index')
|
||||
->with('success', '機台建立成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(Machine $machine)
|
||||
{
|
||||
return view('admin.machines.show', compact('machine'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit(Machine $machine)
|
||||
{
|
||||
return view('admin.machines.edit', compact('machine'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
* AJAX: 取得機台抽屜面板所需的歷程日誌
|
||||
*/
|
||||
public function update(Request $request, Machine $machine)
|
||||
public function logsAjax(Request $request, Machine $machine)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'status' => 'required|in:online,offline,error',
|
||||
'temperature' => 'nullable|numeric',
|
||||
'firmware_version' => 'nullable|string|max:50',
|
||||
]);
|
||||
$per_page = $request->input('per_page', 20);
|
||||
|
||||
$startDate = $request->get('start_date', now()->format('Y-m-d'));
|
||||
$endDate = $request->get('end_date', now()->format('Y-m-d'));
|
||||
|
||||
$machine->update($validated);
|
||||
$logs = $machine->logs()
|
||||
->when($request->level, function ($query, $level) {
|
||||
return $query->where('level', $level);
|
||||
})
|
||||
->whereDate('created_at', '>=', $startDate)
|
||||
->whereDate('created_at', '<=', $endDate)
|
||||
->when($request->type, function ($query, $type) {
|
||||
return $query->where('type', $type);
|
||||
})
|
||||
->latest()
|
||||
->paginate($per_page);
|
||||
|
||||
return redirect()->route('admin.machines.index')
|
||||
->with('success', '機台更新成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Machine $machine)
|
||||
{
|
||||
$machine->delete();
|
||||
|
||||
return redirect()->route('admin.machines.index')
|
||||
->with('success', '機台已刪除');
|
||||
}
|
||||
|
||||
// 機台日誌
|
||||
public function logs()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '機台日誌',
|
||||
'description' => '機台操作歷史紀錄回溯',
|
||||
'features' => [
|
||||
'操作時間戳記',
|
||||
'事件類型分類',
|
||||
'操作人員記錄',
|
||||
'詳細描述查詢',
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $logs->items(),
|
||||
'pagination' => [
|
||||
'total' => $logs->total(),
|
||||
'current_page' => $logs->currentPage(),
|
||||
'last_page' => $logs->lastPage(),
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
// 機台權限
|
||||
public function permissions()
|
||||
|
||||
/**
|
||||
* AJAX: 取得特定帳號的機台分配狀態
|
||||
*/
|
||||
public function getAccountMachines(\App\Models\System\User $user)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '機台權限',
|
||||
'description' => '機台存取權限控管',
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 安全檢查:只能操作自己公司的帳號(除非是系統管理員)
|
||||
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
// 取得該公司所有機台 (限定 company_id 以實作資料隔離)
|
||||
$machines = Machine::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
|
||||
]);
|
||||
}
|
||||
|
||||
// 機台稼動率
|
||||
public function utilization()
|
||||
/**
|
||||
* AJAX: 儲存特定帳號的機台分配
|
||||
*/
|
||||
public function syncAccountMachines(Request $request, \App\Models\System\User $user)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '機台稼動率',
|
||||
'description' => '機台運行效率分析',
|
||||
$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 都屬於該使用者的公司
|
||||
if ($request->has('machine_ids')) {
|
||||
$machineIds = array_unique($request->machine_ids);
|
||||
$validCount = Machine::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()
|
||||
]);
|
||||
}
|
||||
|
||||
// 效期管理
|
||||
public function expiry()
|
||||
/**
|
||||
* 機台使用率統計
|
||||
*/
|
||||
public function utilization(Request $request): View
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '效期管理',
|
||||
'description' => '商品效期與貨道出貨控制',
|
||||
// 取得當前使用者有權限的所有機台 (已透過 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()
|
||||
]);
|
||||
}
|
||||
|
||||
// 維修管理單
|
||||
public function maintenance()
|
||||
/**
|
||||
* AJAX: 取得機台所有貨道資訊 (供效期管理視覺化圖表使用)
|
||||
*/
|
||||
public function slotsAjax(Machine $machine)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '維修管理單',
|
||||
'description' => '機台維修工單系統',
|
||||
$slots = $machine->slots()->with('product:id,name,image')->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)
|
||||
{
|
||||
$request->validate([
|
||||
'slot_no' => 'required|integer',
|
||||
'expiry_date' => 'nullable|date',
|
||||
'apply_all_same_product' => 'boolean'
|
||||
]);
|
||||
|
||||
$slotNo = $request->slot_no;
|
||||
$expiryDate = $request->expiry_date;
|
||||
$applyAll = $request->apply_all_same_product ?? false;
|
||||
|
||||
$slot = $machine->slots()->where('slot_no', $slotNo)->firstOrFail();
|
||||
$slot->update(['expiry_date' => $expiryDate]);
|
||||
|
||||
if ($applyAll && $slot->product_id) {
|
||||
$machine->slots()
|
||||
->where('product_id', $slot->product_id)
|
||||
->update(['expiry_date' => $expiryDate]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Expiry 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
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 機台維護紀錄 (開發中)
|
||||
*/
|
||||
public function maintenance(Request $request): View
|
||||
{
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
}
|
||||
}
|
||||
|
||||
105
app/Http/Controllers/Admin/MaintenanceController.php
Normal file
105
app/Http/Controllers/Admin/MaintenanceController.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?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'])
|
||||
->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
|
||||
]);
|
||||
|
||||
$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'],
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.maintenance.index')
|
||||
->with('success', __('Maintenance record created successfully'));
|
||||
}
|
||||
}
|
||||
62
app/Http/Controllers/Admin/MembershipTierController.php
Normal file
62
app/Http/Controllers/Admin/MembershipTierController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Member\MembershipTier;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MembershipTierController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$tiers = MembershipTier::orderBy('sort_order')->get();
|
||||
return view('admin.membership-tiers.index', compact('tiers'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'annual_fee' => 'required|numeric|min:0',
|
||||
'discount_rate' => 'required|numeric|min:0|max:1',
|
||||
'point_multiplier' => 'required|numeric|min:0',
|
||||
'description' => 'nullable|string',
|
||||
'is_default' => 'boolean',
|
||||
]);
|
||||
|
||||
if ($request->is_default) {
|
||||
MembershipTier::where('is_default', true)->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
MembershipTier::create($validated);
|
||||
|
||||
return redirect()->route('admin.membership-tiers.index')->with('success', '會員等級已建立');
|
||||
}
|
||||
|
||||
public function update(Request $request, MembershipTier $membershipTier)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'annual_fee' => 'required|numeric|min:0',
|
||||
'discount_rate' => 'required|numeric|min:0|max:1',
|
||||
'point_multiplier' => 'required|numeric|min:0',
|
||||
'description' => 'nullable|string',
|
||||
'is_default' => 'boolean',
|
||||
]);
|
||||
|
||||
if ($request->is_default && !$membershipTier->is_default) {
|
||||
MembershipTier::where('is_default', true)->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
$membershipTier->update($validated);
|
||||
|
||||
return redirect()->route('admin.membership-tiers.index')->with('success', '會員等級已更新');
|
||||
}
|
||||
|
||||
public function destroy(MembershipTier $membershipTier)
|
||||
{
|
||||
$membershipTier->delete();
|
||||
return redirect()->route('admin.membership-tiers.index')->with('success', '會員等級已刪除');
|
||||
}
|
||||
}
|
||||
@@ -7,111 +7,500 @@ use Illuminate\Http\Request;
|
||||
|
||||
class PermissionController extends Controller
|
||||
{
|
||||
// APP功能管理
|
||||
public function appFeatures()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => 'APP功能管理',
|
||||
'description' => 'APP功能權限設定',
|
||||
]);
|
||||
}
|
||||
|
||||
// 資料設定權限
|
||||
public function dataConfig()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '資料設定權限',
|
||||
'description' => '資料設定功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 銷售管理權限
|
||||
public function sales()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '銷售管理權限',
|
||||
'description' => '銷售管理功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 機台管理權限
|
||||
public function machines()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '機台管理權限',
|
||||
'description' => '機台管理功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 倉庫管理權限
|
||||
public function warehouses()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '倉庫管理權限',
|
||||
'description' => '倉庫管理功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 分析管理權限
|
||||
public function analysis()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '分析管理權限',
|
||||
'description' => '分析管理功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 稽核管理權限
|
||||
public function audit()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '稽核管理權限',
|
||||
'description' => '稽核管理功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 遠端管理權限
|
||||
public function remote()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端管理權限',
|
||||
'description' => '遠端管理功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// Line管理權限
|
||||
public function line()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => 'Line管理權限',
|
||||
'description' => 'Line管理功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 權限角色設定
|
||||
public function roles()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '權限角色設定',
|
||||
'description' => '角色權限組合設定',
|
||||
]);
|
||||
$per_page = request()->input('per_page', 10);
|
||||
$user = auth()->user();
|
||||
$query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
|
||||
|
||||
// 租戶隔離:租戶只能看到自己公司的角色
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$query->where('company_id', $user->company_id);
|
||||
}
|
||||
|
||||
// 搜尋:角色名稱
|
||||
if ($search = request()->input('search')) {
|
||||
$query->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
// 篩選:所屬單位 (僅限系統管理員)
|
||||
if ($user->isSystemAdmin() && request()->filled('company_id')) {
|
||||
if (request()->company_id === 'system') {
|
||||
$query->where('is_system', true);
|
||||
} else {
|
||||
$query->where('company_id', request()->company_id);
|
||||
}
|
||||
}
|
||||
|
||||
$roles = $query->latest()->paginate($per_page)->withQueryString();
|
||||
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
|
||||
|
||||
// 權限遞迴約束:租戶管理員只能看到並指派自己擁有的權限
|
||||
$permissionQuery = \Spatie\Permission\Models\Permission::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
|
||||
}
|
||||
|
||||
// 權限分組邏輯
|
||||
$all_permissions = $permissionQuery->get()
|
||||
->groupBy(function($perm) {
|
||||
if (str_starts_with($perm->name, 'menu.')) {
|
||||
// 主選單權限:menu.xxx (兩段)
|
||||
// 子選單權限:menu.xxx.yyy (三段)
|
||||
return 'menu';
|
||||
}
|
||||
return 'other';
|
||||
});
|
||||
|
||||
// 根據路由決定標題
|
||||
$title = request()->routeIs('*.sub-account-roles') ? __('Sub Account Roles') : __('Role Settings');
|
||||
|
||||
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
|
||||
return view('admin.permission.roles', compact('roles', 'all_permissions', 'title', 'currentUserRoleIds', 'companies'));
|
||||
}
|
||||
|
||||
// 其他功能管理
|
||||
public function others()
|
||||
/**
|
||||
* Show the form for creating a new role.
|
||||
*/
|
||||
public function createRole()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '其他功能管理',
|
||||
'description' => '其他特殊功能權限',
|
||||
]);
|
||||
$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-account-roles') : route('admin.permission.roles');
|
||||
|
||||
return view('admin.permission.roles-edit', compact('role', 'all_permissions', 'title', 'back_url'));
|
||||
}
|
||||
|
||||
// AI智能預測
|
||||
public function aiPrediction()
|
||||
/**
|
||||
* Show the form for editing the specified role.
|
||||
*/
|
||||
public function editRole($id)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => 'AI智能預測',
|
||||
'description' => 'AI功能權限設定',
|
||||
$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()
|
||||
->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-account-roles') : route('admin.permission.roles');
|
||||
|
||||
return view('admin.permission.roles-edit', compact('role', 'all_permissions', 'title', 'back_url'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created role in storage.
|
||||
*/
|
||||
public function storeRole(Request $request)
|
||||
{
|
||||
$is_system = auth()->user()->isSystemAdmin() && $request->boolean('is_system');
|
||||
$company_id = $is_system ? null : auth()->user()->company_id;
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => [
|
||||
'required', 'string', 'max:255',
|
||||
\Illuminate\Validation\Rule::unique('roles', 'name')->where(function ($query) use ($company_id) {
|
||||
return $query->where('company_id', $company_id);
|
||||
})
|
||||
],
|
||||
'permissions' => 'nullable|array',
|
||||
'permissions.*' => 'string|exists:permissions,name',
|
||||
]);
|
||||
|
||||
$role = \App\Models\System\Role::query()->create([
|
||||
'name' => $validated['name'],
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $is_system ? null : auth()->user()->company_id,
|
||||
'is_system' => $is_system,
|
||||
]);
|
||||
|
||||
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']);
|
||||
}
|
||||
$role->syncPermissions($perms);
|
||||
}
|
||||
|
||||
$target_route = request()->routeIs('*.sub-account-roles.*') ? 'admin.data-config.sub-account-roles' : 'admin.permission.roles';
|
||||
return redirect()->route($target_route)->with('success', __('Role created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified role in storage.
|
||||
*/
|
||||
public function updateRole(Request $request, $id)
|
||||
{
|
||||
$role = \App\Models\System\Role::findOrFail($id);
|
||||
|
||||
$is_system = $role->is_system;
|
||||
$company_id = $role->company_id;
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => [
|
||||
'required', 'string', 'max:255',
|
||||
\Illuminate\Validation\Rule::unique('roles', 'name')
|
||||
->ignore($id)
|
||||
->where(function ($query) use ($company_id) {
|
||||
return $query->where('company_id', $company_id);
|
||||
})
|
||||
],
|
||||
'permissions' => 'nullable|array',
|
||||
'permissions.*' => 'string|exists:permissions,name',
|
||||
]);
|
||||
|
||||
if ($role->name === 'super-admin') {
|
||||
return redirect()->back()->with('error', __('The Super Admin role is immutable.'));
|
||||
}
|
||||
|
||||
if (!auth()->user()->isSystemAdmin() && $role->is_system) {
|
||||
return redirect()->back()->with('error', __('System roles cannot be modified by tenant administrators.'));
|
||||
}
|
||||
|
||||
$is_system = auth()->user()->isSystemAdmin() ? $request->boolean('is_system') : $role->is_system;
|
||||
|
||||
$role->update([
|
||||
'name' => $validated['name'],
|
||||
'is_system' => $is_system,
|
||||
'company_id' => $is_system ? null : $role->company_id,
|
||||
]);
|
||||
|
||||
$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);
|
||||
|
||||
$target_route = request()->routeIs('*.sub-account-roles.*') ? 'admin.data-config.sub-account-roles' : 'admin.permission.roles';
|
||||
return redirect()->route($target_route)->with('success', __('Role updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified role from storage.
|
||||
*/
|
||||
public function destroyRole($id)
|
||||
{
|
||||
$role = \App\Models\System\Role::findOrFail($id);
|
||||
|
||||
if ($role->name === 'super-admin') {
|
||||
return redirect()->back()->with('error', __('The Super Admin role cannot be deleted.'));
|
||||
}
|
||||
|
||||
if (!auth()->user()->isSystemAdmin() && $role->is_system) {
|
||||
return redirect()->back()->with('error', __('System roles cannot be deleted by tenant administrators.'));
|
||||
}
|
||||
|
||||
if ($role->users()->count() > 0) {
|
||||
return redirect()->back()->with('error', __('Cannot delete role with active users.'));
|
||||
}
|
||||
|
||||
$role->delete();
|
||||
|
||||
return redirect()->back()->with('success', __('Role deleted successfully.'));
|
||||
}
|
||||
|
||||
// 帳號管理
|
||||
public function accounts(Request $request)
|
||||
{
|
||||
$query = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
|
||||
|
||||
// 租戶隔離:如果不是系統管理員,則只看自己公司的成員
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$query->where('company_id', auth()->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}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 公司篩選 (僅限 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::query();
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$roles_query->forCompany(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'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created account in storage.
|
||||
*/
|
||||
public function storeAccount(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'username' => 'required|string|max:255|unique:users,username',
|
||||
'email' => 'nullable|email|max:255|unique:users,email',
|
||||
'password' => 'required|string|min:8',
|
||||
'role' => 'required|string',
|
||||
'status' => 'required|boolean',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
$company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
|
||||
|
||||
// 查找角色:優先尋找該公司的角色,若無則尋找全域範本
|
||||
$role = \App\Models\System\Role::where('name', $validated['role'])
|
||||
->where(function($q) use ($company_id) {
|
||||
$q->where('company_id', $company_id)->orWhereNull('company_id');
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$role) {
|
||||
return redirect()->back()->with('error', __('Role not found.'));
|
||||
}
|
||||
|
||||
// 驗證角色與公司的匹配性 (RBAC Safeguard)
|
||||
if ($company_id !== null) {
|
||||
// 如果是租戶帳號,不能選超級管理員角色
|
||||
if ($role->is_system && $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)
|
||||
if (!$role->is_system) {
|
||||
return redirect()->back()->with('error', __('Only system roles can be assigned to platform administrative accounts.'));
|
||||
}
|
||||
}
|
||||
|
||||
// 角色初始化與克隆邏輯 (只有 super-admin 在幫空白公司開帳號時觸發)
|
||||
$company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
|
||||
|
||||
if ($company_id && $role && $role->company_id === null && $role->name !== 'super-admin') {
|
||||
// 檢查該公司是否已有名為「管理員」的角色
|
||||
$existingRole = \App\Models\System\Role::where('company_id', $company_id)
|
||||
->where('name', '管理員')
|
||||
->first();
|
||||
|
||||
if (!$existingRole) {
|
||||
// 克隆範本為該公司的「管理員」
|
||||
$newRole = \App\Models\System\Role::query()->create([
|
||||
'name' => '管理員',
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $company_id,
|
||||
'is_system' => false,
|
||||
]);
|
||||
$newRole->syncPermissions($role->getPermissionNames());
|
||||
$role = $newRole;
|
||||
} else {
|
||||
// 如果已存在名為「管理員」的角色,則直接使用它
|
||||
$role = $existingRole;
|
||||
}
|
||||
}
|
||||
|
||||
$user = \App\Models\System\User::create([
|
||||
'name' => $validated['name'],
|
||||
'username' => $validated['username'],
|
||||
'email' => $validated['email'],
|
||||
'password' => \Illuminate\Support\Facades\Hash::make($validated['password']),
|
||||
'status' => $validated['status'],
|
||||
'company_id' => $company_id,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
]);
|
||||
|
||||
$user->assignRole($role);
|
||||
|
||||
return redirect()->back()->with('success', __('Account created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified account in storage.
|
||||
*/
|
||||
public function updateAccount(Request $request, $id)
|
||||
{
|
||||
$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.'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'username' => 'required|string|max:255|unique:users,username,' . $id,
|
||||
'email' => 'nullable|email|max:255|unique:users,email,' . $id,
|
||||
'password' => 'nullable|string|min:8',
|
||||
'role' => 'required|string',
|
||||
'status' => 'required|boolean',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
$target_company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
|
||||
|
||||
// 查找角色:優先尋找該公司的角色,若無則尋找全域範本
|
||||
$roleObj = \App\Models\System\Role::where('name', $validated['role'])
|
||||
->where(function($q) use ($target_company_id) {
|
||||
$q->where('company_id', $target_company_id)->orWhereNull('company_id');
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$roleObj) {
|
||||
return redirect()->back()->with('error', __('Role not found.'));
|
||||
}
|
||||
|
||||
// 驗證角色與公司的匹配性 (RBAC Safeguard)
|
||||
if ($user->id !== auth()->id()) { // 排除編輯自己 (super-admin 有特殊邏輯)
|
||||
if ($target_company_id !== null) {
|
||||
if ($roleObj->is_system && $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.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'name' => $validated['name'],
|
||||
'username' => $validated['username'],
|
||||
'email' => $validated['email'],
|
||||
'status' => $validated['status'],
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
];
|
||||
|
||||
if (auth()->user()->isSystemAdmin()) {
|
||||
// 防止超級管理員不小心把自己綁定到租客公司或降級
|
||||
if ($user->id === auth()->id()) {
|
||||
$updateData['company_id'] = null;
|
||||
$validated['role'] = 'super-admin';
|
||||
} else {
|
||||
$updateData['company_id'] = $validated['company_id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($validated['password'])) {
|
||||
$updateData['password'] = \Illuminate\Support\Facades\Hash::make($validated['password']);
|
||||
}
|
||||
|
||||
// 角色初始化與克隆邏輯
|
||||
$target_company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
|
||||
|
||||
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) {
|
||||
$newRole = \App\Models\System\Role::query()->create([
|
||||
'name' => '管理員',
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $target_company_id,
|
||||
'is_system' => false,
|
||||
]);
|
||||
$newRole->syncPermissions($roleObj->getPermissionNames());
|
||||
$roleObj = $newRole;
|
||||
} else {
|
||||
$roleObj = $existingRole;
|
||||
}
|
||||
}
|
||||
|
||||
$user->update($updateData);
|
||||
|
||||
// 如果是編輯自己且原本是超級管理員,強制保留 super-admin 角色
|
||||
if ($user->id === auth()->id() && auth()->user()->isSystemAdmin()) {
|
||||
$user->syncRoles(['super-admin']);
|
||||
} else {
|
||||
$user->syncRoles([$roleObj]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Account updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified account from storage.
|
||||
*/
|
||||
public function destroyAccount($id)
|
||||
{
|
||||
$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->id === auth()->id()) {
|
||||
return redirect()->back()->with('error', __('You cannot delete your own account.'));
|
||||
}
|
||||
|
||||
// 為了解決軟刪除導致的唯一索引佔用問題,刪除前先重命名唯一欄位
|
||||
$timestamp = now()->getTimestamp();
|
||||
$user->username = $user->username . '.deleted.' . $timestamp;
|
||||
$user->email = $user->email . '.deleted.' . $timestamp;
|
||||
$user->save();
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->back()->with('success', __('Account deleted successfully.'));
|
||||
}
|
||||
}
|
||||
|
||||
54
app/Http/Controllers/Admin/PointRuleController.php
Normal file
54
app/Http/Controllers/Admin/PointRuleController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Member\PointRule;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PointRuleController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$rules = PointRule::all();
|
||||
return view('admin.point-rules.index', compact('rules'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'trigger' => 'required|in:purchase,deposit,register,birthday,referral',
|
||||
'points_per_unit' => 'required|integer|min:1',
|
||||
'unit_amount' => 'required|numeric|min:0',
|
||||
'validity_days' => 'required|integer|min:1',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
PointRule::create($validated);
|
||||
|
||||
return redirect()->route('admin.point-rules.index')->with('success', '點數規則已建立');
|
||||
}
|
||||
|
||||
public function update(Request $request, PointRule $pointRule)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'trigger' => 'required|in:purchase,deposit,register,birthday,referral',
|
||||
'points_per_unit' => 'required|integer|min:1',
|
||||
'unit_amount' => 'required|numeric|min:0',
|
||||
'validity_days' => 'required|integer|min:1',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$pointRule->update($validated);
|
||||
|
||||
return redirect()->route('admin.point-rules.index')->with('success', '點數規則已更新');
|
||||
}
|
||||
|
||||
public function destroy(PointRule $pointRule)
|
||||
{
|
||||
$pointRule->delete();
|
||||
return redirect()->route('admin.point-rules.index')->with('success', '點數規則已刪除');
|
||||
}
|
||||
}
|
||||
260
app/Http/Controllers/Api/MemberController.php
Normal file
260
app/Http/Controllers/Api/MemberController.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Member\Member;
|
||||
use App\Models\Member\SocialAccount;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class MemberController extends Controller
|
||||
{
|
||||
/**
|
||||
* 會員註冊
|
||||
*/
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'email', 'unique:members,email'],
|
||||
'phone' => ['nullable', 'string', 'unique:members,phone'],
|
||||
'password' => ['required', Password::min(6)],
|
||||
'birthday' => ['nullable', 'date'],
|
||||
'gender' => ['nullable', 'in:male,female,other'],
|
||||
], [
|
||||
'name.required' => '請輸入姓名',
|
||||
'email.unique' => '此 Email 已被註冊',
|
||||
'phone.unique' => '此手機號碼已被註冊',
|
||||
'password.required' => '請輸入密碼',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '驗證失敗',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 必須提供 email 或 phone 其中之一
|
||||
if (empty($request->email) && empty($request->phone)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '請提供 Email 或手機號碼',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$member = Member::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'phone' => $request->phone,
|
||||
'password' => $request->password,
|
||||
'birthday' => $request->birthday,
|
||||
'gender' => $request->gender,
|
||||
]);
|
||||
|
||||
$token = $member->createToken('member-token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '註冊成功',
|
||||
'data' => [
|
||||
'member' => $member,
|
||||
'token' => $token,
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 會員登入(Email/Phone + Password)
|
||||
*/
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'account' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
], [
|
||||
'account.required' => '請輸入帳號',
|
||||
'password.required' => '請輸入密碼',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '驗證失敗',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 嘗試以 email 或 phone 查詢
|
||||
$member = Member::where('email', $request->account)
|
||||
->orWhere('phone', $request->account)
|
||||
->first();
|
||||
|
||||
if (!$member || !Hash::check($request->password, $member->password)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '帳號或密碼錯誤',
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (!$member->is_active) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '帳號已被停用',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$token = $member->createToken('member-token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '登入成功',
|
||||
'data' => [
|
||||
'member' => $member,
|
||||
'token' => $token,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 社群登入
|
||||
*/
|
||||
public function socialLogin(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'provider' => ['required', 'in:line,google,facebook'],
|
||||
'provider_id' => ['required', 'string'],
|
||||
'access_token' => ['nullable', 'string'],
|
||||
'name' => ['nullable', 'string'],
|
||||
'email' => ['nullable', 'email'],
|
||||
'avatar' => ['nullable', 'string'],
|
||||
], [
|
||||
'provider.required' => '請指定登入平台',
|
||||
'provider.in' => '不支援的登入平台',
|
||||
'provider_id.required' => '缺少社群用戶 ID',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '驗證失敗',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 查詢是否已綁定
|
||||
$socialAccount = SocialAccount::where('provider', $request->provider)
|
||||
->where('provider_id', $request->provider_id)
|
||||
->first();
|
||||
|
||||
if ($socialAccount) {
|
||||
// 已綁定,直接登入
|
||||
$member = $socialAccount->member;
|
||||
|
||||
// 更新 token
|
||||
$socialAccount->update([
|
||||
'access_token' => $request->access_token,
|
||||
]);
|
||||
} else {
|
||||
// 未綁定,建立新會員
|
||||
$member = Member::create([
|
||||
'name' => $request->name ?? '會員',
|
||||
'email' => $request->email,
|
||||
'avatar' => $request->avatar,
|
||||
'email_verified_at' => $request->email ? now() : null, // 社群登入自動驗證
|
||||
]);
|
||||
|
||||
// 綁定社群帳號
|
||||
$member->socialAccounts()->create([
|
||||
'provider' => $request->provider,
|
||||
'provider_id' => $request->provider_id,
|
||||
'access_token' => $request->access_token,
|
||||
'profile_data' => $request->only(['name', 'email', 'avatar']),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$member->is_active) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '帳號已被停用',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$token = $member->createToken('member-token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '登入成功',
|
||||
'data' => [
|
||||
'member' => $member,
|
||||
'token' => $token,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得個人資料
|
||||
*/
|
||||
public function profile(Request $request): JsonResponse
|
||||
{
|
||||
$member = $request->user();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'member' => $member->load('socialAccounts'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新個人資料
|
||||
*/
|
||||
public function updateProfile(Request $request): JsonResponse
|
||||
{
|
||||
$member = $request->user();
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'birthday' => ['nullable', 'date'],
|
||||
'gender' => ['nullable', 'in:male,female,other'],
|
||||
'avatar' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '驗證失敗',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$member->update($request->only(['name', 'birthday', 'gender', 'avatar']));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '更新成功',
|
||||
'data' => [
|
||||
'member' => $member,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '登出成功',
|
||||
]);
|
||||
}
|
||||
}
|
||||
11
app/Http/Controllers/Api/V1/ApiController.php
Normal file
11
app/Http/Controllers/Api/V1/ApiController.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Traits\ApiResponse;
|
||||
|
||||
abstract class ApiController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
}
|
||||
142
app/Http/Controllers/Api/V1/App/MachineController.php
Normal file
142
app/Http/Controllers/Api/V1/App/MachineController.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Jobs\Machine\ProcessHeartbeat;
|
||||
use App\Jobs\Machine\ProcessTimerStatus;
|
||||
use App\Jobs\Machine\ProcessCoinInventory;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class MachineController extends Controller
|
||||
{
|
||||
/**
|
||||
* B010: Machine Heartbeat & Status Update (Asynchronous)
|
||||
*/
|
||||
public function heartbeat(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
|
||||
// 異步處理狀態更新
|
||||
ProcessHeartbeat::dispatch($machine->serial_no, $data);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'OK',
|
||||
'status' => '49' // 某些硬體可能需要的成功碼
|
||||
], 202); // 202 Accepted
|
||||
}
|
||||
|
||||
/**
|
||||
* B018: Record Machine Restock/Setup Report (Asynchronous)
|
||||
*/
|
||||
public function recordRestock(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$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)
|
||||
*/
|
||||
public function getSlots(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$slots = $machine->slots()->with('product')->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => $slots->map(function ($slot) {
|
||||
return [
|
||||
'slot_no' => $slot->slot_no,
|
||||
'product_id' => $slot->product_id,
|
||||
'stock' => $slot->stock,
|
||||
'capacity' => $slot->capacity,
|
||||
'price' => $slot->price,
|
||||
'status' => $slot->status,
|
||||
];
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B710: Sync Timer status (Asynchronous)
|
||||
*/
|
||||
public function syncTimer(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
|
||||
ProcessTimerStatus::dispatch($machine->serial_no, $data);
|
||||
|
||||
return response()->json(['success' => true], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* B220: Sync Coin Inventory (Asynchronous)
|
||||
*/
|
||||
public function syncCoinInventory(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
|
||||
ProcessCoinInventory::dispatch($machine->serial_no, $data);
|
||||
|
||||
return response()->json(['success' => true], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* B650: Verify Member Code/Barcode (Synchronous)
|
||||
*/
|
||||
public function verifyMember(Request $request)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'code' => 'required|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['success' => false, 'message' => 'Invalid code'], 400);
|
||||
}
|
||||
|
||||
$code = $request->input('code');
|
||||
|
||||
// 搜尋會員 (barcode 或特定驗證碼)
|
||||
$member = \App\Models\Member\Member::where('barcode', $code)
|
||||
->orWhere('id', $code) // 暫時支援 ID
|
||||
->first();
|
||||
|
||||
if (!$member) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 404,
|
||||
'message' => 'Member not found'
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => [
|
||||
'member_id' => $member->id,
|
||||
'name' => $member->name,
|
||||
'points' => $member->points,
|
||||
'wallet_balance' => $member->wallet_balance ?? 0,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
66
app/Http/Controllers/Api/V1/App/TransactionController.php
Normal file
66
app/Http/Controllers/Api/V1/App/TransactionController.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Jobs\Transaction\ProcessTransaction;
|
||||
use App\Jobs\Transaction\ProcessInvoice;
|
||||
use App\Jobs\Transaction\ProcessDispenseRecord;
|
||||
|
||||
class TransactionController extends Controller
|
||||
{
|
||||
/**
|
||||
* B600: Record Transaction (Asynchronous)
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$data['serial_no'] = $machine->serial_no;
|
||||
|
||||
ProcessTransaction::dispatch($data);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Accepted'
|
||||
], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* B601: Record Invoice (Asynchronous)
|
||||
*/
|
||||
public function recordInvoice(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$data['serial_no'] = $machine->serial_no;
|
||||
|
||||
ProcessInvoice::dispatch($data);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Accepted'
|
||||
], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* B602: Record Dispense Result (Asynchronous)
|
||||
*/
|
||||
public function recordDispense(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$data['serial_no'] = $machine->serial_no;
|
||||
|
||||
ProcessDispenseRecord::dispatch($data);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Accepted'
|
||||
], 202);
|
||||
}
|
||||
}
|
||||
39
app/Http/Controllers/Api/V1/MachineController.php
Normal file
39
app/Http/Controllers/Api/V1/MachineController.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Jobs\Machine\ProcessMachineLog;
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class MachineController extends ApiController
|
||||
{
|
||||
/**
|
||||
* 接收機台回傳的日誌 (IoT Endpoint)
|
||||
* 採用異步處理 (Queue)
|
||||
*/
|
||||
public function storeLog(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'level' => 'required|string|in:info,warning,error',
|
||||
'message' => 'required|string',
|
||||
'context' => 'nullable|array',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return $this->errorResponse('Validation error', 422, $validator->errors());
|
||||
}
|
||||
|
||||
// 檢查機台是否存在
|
||||
if (!Machine::where('id', $id)->exists()) {
|
||||
return $this->errorResponse('Machine not found', 404);
|
||||
}
|
||||
|
||||
// 丟入隊列進行異步處理,回傳 202 Accepted
|
||||
ProcessMachineLog::dispatch($id, $request->only(['level', 'message', 'context']));
|
||||
|
||||
return $this->successResponse([], 'Log accepted. Processing asynchronously.', 202);
|
||||
}
|
||||
}
|
||||
11
app/Http/Controllers/Api/V1/MemberController.php
Normal file
11
app/Http/Controllers/Api/V1/MemberController.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MemberController extends Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\System\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
25
app/Http/Controllers/MemberController.php
Normal file
25
app/Http/Controllers/MemberController.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Member\Member;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class MemberController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the members.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$members = Member::query()
|
||||
->latest()
|
||||
->paginate(10);
|
||||
|
||||
return view('admin.members.index', [
|
||||
'members' => $members,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProfileController extends Controller
|
||||
@@ -16,8 +17,11 @@ class ProfileController extends Controller
|
||||
*/
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
// 只取最新 10 筆登入紀錄
|
||||
'user' => $user->load(['loginLogs' => fn($q) => $q->latest('login_at')->limit(10)]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -26,35 +30,50 @@ class ProfileController extends Controller
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
$user = $request->user();
|
||||
$user->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
if ($user->isDirty('email')) {
|
||||
$user->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
$user->save();
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
return Redirect::route('profile.edit')->with('success', __('Profile updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
* Update the user's avatar via AJAX.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
public function updateAvatar(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$request->validateWithBag('userDeletion', [
|
||||
'password' => ['required', 'current_password'],
|
||||
$request->validate([
|
||||
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif', 'max:1024'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
if ($request->hasFile('avatar')) {
|
||||
// Delete old avatar if exists
|
||||
if ($user->avatar) {
|
||||
Storage::disk('public')->delete($user->avatar);
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
$path = $request->file('avatar')->store('avatars', 'public');
|
||||
$user->avatar = $path;
|
||||
$user->save();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'avatar_url' => $user->avatar_url,
|
||||
'message' => __('Avatar updated successfully.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return Redirect::to('/');
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('No file uploaded.'),
|
||||
], 400);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
33
app/Http/Controllers/SocialLoginTestController.php
Normal file
33
app/Http/Controllers/SocialLoginTestController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SocialLoginTestController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('test.social-login');
|
||||
}
|
||||
|
||||
public function lineCallback(Request $request)
|
||||
{
|
||||
// 這裡可以實作後端換發 Token 的邏輯
|
||||
// 為了測試方便,我們先直接顯示回傳的 code 與 state
|
||||
// 或者嘗試交換 Token 並取得 User Profile
|
||||
|
||||
$code = $request->input('code');
|
||||
$state = $request->input('state');
|
||||
$error = $request->input('error');
|
||||
|
||||
return view('test.social-login', [
|
||||
'line_data' => [
|
||||
'code' => $code,
|
||||
'state' => $state,
|
||||
'error' => $error
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
25
app/Http/Controllers/System/LanguageController.php
Normal file
25
app/Http/Controllers/System/LanguageController.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\System;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
class LanguageController extends Controller
|
||||
{
|
||||
/**
|
||||
* Switch application language.
|
||||
*
|
||||
* @param string $locale
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function switch($locale)
|
||||
{
|
||||
if (in_array($locale, ['en', 'zh_TW', 'ja'])) {
|
||||
Session::put('locale', $locale);
|
||||
}
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ class Kernel extends HttpKernel
|
||||
\App\Http\Middleware\EncryptCookies::class,
|
||||
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
\App\Http\Middleware\SetLocale::class,
|
||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
@@ -64,5 +65,10 @@ class Kernel extends HttpKernel
|
||||
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
||||
'tenant.access' => \App\Http\Middleware\EnsureTenantAccess::class,
|
||||
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
|
||||
'iot.auth' => \App\Http\Middleware\IotAuth::class,
|
||||
];
|
||||
}
|
||||
|
||||
37
app/Http/Middleware/EnsureTenantAccess.php
Normal file
37
app/Http/Middleware/EnsureTenantAccess.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureTenantAccess
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// 如果是租戶帳號,檢查公司狀態
|
||||
if ($user && $user->isTenant()) {
|
||||
$company = $user->company;
|
||||
|
||||
if (!$company || $company->status === 0) {
|
||||
auth()->logout();
|
||||
return redirect()->route('login')->with('error', __('Your account is associated with a deactivated company.'));
|
||||
}
|
||||
|
||||
if ($company->valid_until && $company->valid_until->isPast()) {
|
||||
auth()->logout();
|
||||
return redirect()->route('login')->with('error', __('Your company contract has expired.'));
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
39
app/Http/Middleware/IotAuth.php
Normal file
39
app/Http/Middleware/IotAuth.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Machine\Machine;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class IotAuth
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$token = $request->bearerToken();
|
||||
|
||||
// Phase 1: 暫時也接受 Request Body 中的 key 欄位 (相容模式)
|
||||
if (!$token) {
|
||||
$token = $request->input('key');
|
||||
}
|
||||
|
||||
if (!$token) {
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized: Missing Token'], 401);
|
||||
}
|
||||
|
||||
$machine = Machine::where('api_token', $token)->first();
|
||||
|
||||
if (!$machine) {
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized: Invalid Token'], 401);
|
||||
}
|
||||
|
||||
// 將機台物件注入 Request 供後端使用
|
||||
$request->merge(['machine' => $machine]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
27
app/Http/Middleware/SetLocale.php
Normal file
27
app/Http/Middleware/SetLocale.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (session()->has('locale')) {
|
||||
$locale = session()->get('locale');
|
||||
if (in_array($locale, ['zh_TW', 'en', 'ja'])) {
|
||||
app()->setLocale($locale);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class TrustProxies extends Middleware
|
||||
*
|
||||
* @var array<int, string>|string|null
|
||||
*/
|
||||
protected $proxies;
|
||||
protected $proxies = '*';
|
||||
|
||||
/**
|
||||
* The headers that should be used to detect proxies.
|
||||
|
||||
@@ -27,11 +27,22 @@ class LoginRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'username' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得驗證規則的自訂錯誤訊息
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'username.required' => '請輸入帳號',
|
||||
'password.required' => '請輸入密碼',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
@@ -41,11 +52,11 @@ class LoginRequest extends FormRequest
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||
if (! Auth::attempt($this->only('username', 'password'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.failed'),
|
||||
'username' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -68,7 +79,7 @@ class LoginRequest extends FormRequest
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.throttle', [
|
||||
'username' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
@@ -80,6 +91,6 @@ class LoginRequest extends FormRequest
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||
return Str::transliterate(Str::lower($this->string('username')).'|'.$this->ip());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\System\User;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -19,7 +19,7 @@ class ProfileUpdateRequest extends FormRequest
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
|
||||
'phone' => ['nullable', 'string', 'max:20'],
|
||||
'avatar' => ['nullable', 'string', 'max:255'],
|
||||
'avatar' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif', 'max:2048'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
57
app/Jobs/Machine/ProcessCoinInventory.php
Normal file
57
app/Jobs/Machine/ProcessCoinInventory.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Machine;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\CoinInventory;
|
||||
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 ProcessCoinInventory 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(): void
|
||||
{
|
||||
try {
|
||||
$machine = Machine::where('serial_no', $this->serialNo)->firstOrFail();
|
||||
|
||||
// Sync inventory: typically the IoT device sends the full state
|
||||
// If it sends partial, logic would differ. For now, we assume simple updateOrCreate per denomination.
|
||||
if (isset($this->data['inventories']) && is_array($this->data['inventories'])) {
|
||||
foreach ($this->data['inventories'] as $inv) {
|
||||
CoinInventory::updateOrCreate(
|
||||
[
|
||||
'machine_id' => $machine->id,
|
||||
'denomination' => $inv['denomination'],
|
||||
'type' => $inv['type'] ?? 'coin'
|
||||
],
|
||||
['count' => $inv['count']]
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to process coin inventory for machine {$this->serialNo}: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/Jobs/Machine/ProcessHeartbeat.php
Normal file
41
app/Jobs/Machine/ProcessHeartbeat.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\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;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessHeartbeat 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 $machineService): void
|
||||
{
|
||||
try {
|
||||
$machineService->updateHeartbeat($this->serialNo, $this->data);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to process heartbeat for machine {$this->serialNo}: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
app/Jobs/Machine/ProcessMachineLog.php
Normal file
46
app/Jobs/Machine/ProcessMachineLog.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\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;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessMachineLog implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $machineId;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $logData;
|
||||
|
||||
public function __construct(int $machineId, array $logData)
|
||||
{
|
||||
$this->machineId = $machineId;
|
||||
$this->logData = $logData;
|
||||
}
|
||||
|
||||
public function getMachineId(): int
|
||||
{
|
||||
return $this->machineId;
|
||||
}
|
||||
|
||||
public function handle(MachineService $service): void
|
||||
{
|
||||
try {
|
||||
$service->recordLog($this->machineId, $this->logData);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to process machine log for machine {$this->machineId}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/Jobs/Machine/ProcessRestockReport.php
Normal file
37
app/Jobs/Machine/ProcessRestockReport.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Machine;
|
||||
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class ProcessRestockReport implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
protected $data;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(\App\Services\Machine\MachineService $machineService): void
|
||||
{
|
||||
$serialNo = $this->data['serial_no'] ?? null;
|
||||
$slotsData = $this->data['slots'] ?? [];
|
||||
|
||||
if (!$serialNo) return;
|
||||
|
||||
$machine = \App\Models\Machine\Machine::where('serial_no', $serialNo)->first();
|
||||
if ($machine) {
|
||||
$machineService->syncSlots($machine, $slotsData);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/Jobs/Machine/ProcessTimerStatus.php
Normal file
51
app/Jobs/Machine/ProcessTimerStatus.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Machine;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\TimerStatus;
|
||||
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 ProcessTimerStatus 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(): void
|
||||
{
|
||||
try {
|
||||
$machine = Machine::where('serial_no', $this->serialNo)->firstOrFail();
|
||||
|
||||
TimerStatus::updateOrCreate(
|
||||
['machine_id' => $machine->id, 'slot_no' => $this->data['slot_no']],
|
||||
[
|
||||
'status' => $this->data['status'],
|
||||
'remaining_seconds' => $this->data['remaining_seconds'],
|
||||
'end_at' => isset($this->data['end_at']) ? \Carbon\Carbon::parse($this->data['end_at']) : null,
|
||||
]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to process timer status for machine {$this->serialNo}: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/Jobs/Transaction/ProcessDispenseRecord.php
Normal file
39
app/Jobs/Transaction/ProcessDispenseRecord.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Transaction;
|
||||
|
||||
use App\Services\Transaction\TransactionService;
|
||||
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 ProcessDispenseRecord implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $data;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(TransactionService $transactionService): void
|
||||
{
|
||||
try {
|
||||
$transactionService->recordDispense($this->data);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to record dispense for machine {$this->data['serial_no']}: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/Jobs/Transaction/ProcessInvoice.php
Normal file
42
app/Jobs/Transaction/ProcessInvoice.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Transaction;
|
||||
|
||||
use App\Services\Transaction\TransactionService;
|
||||
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 ProcessInvoice implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $data;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(TransactionService $transactionService): void
|
||||
{
|
||||
try {
|
||||
$transactionService->recordInvoice($this->data);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to process invoice: ' . $e->getMessage(), [
|
||||
'data' => $this->data,
|
||||
'exception' => $e
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/Jobs/Transaction/ProcessTransaction.php
Normal file
39
app/Jobs/Transaction/ProcessTransaction.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Transaction;
|
||||
|
||||
use App\Services\Transaction\TransactionService;
|
||||
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 ProcessTransaction implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $data;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(TransactionService $transactionService): void
|
||||
{
|
||||
try {
|
||||
$transactionService->processTransaction($this->data);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to process transaction: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
app/Listeners/LogSuccessfulLogin.php
Normal file
70
app/Listeners/LogSuccessfulLogin.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\System\UserLoginLog;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LogSuccessfulLogin
|
||||
{
|
||||
/**
|
||||
* The request instance.
|
||||
*
|
||||
* @var \Illuminate\Http\Request
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* Create the event listener.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Request $request)
|
||||
{
|
||||
$this->request = $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the event.
|
||||
*
|
||||
* @param \Illuminate\Auth\Events\Login $event
|
||||
* @return void
|
||||
*/
|
||||
public function handle(Login $event)
|
||||
{
|
||||
$ip = $this->request->ip();
|
||||
$userAgent = $this->request->userAgent();
|
||||
|
||||
// 防重覆機制 (Debouncing): 10 秒內同使用者、同 IP 的記錄視為重複
|
||||
$recentLog = UserLoginLog::where('user_id', $event->user->id)
|
||||
->where('ip_address', $ip)
|
||||
->where('login_at', '>=', now()->subSeconds(10))
|
||||
->first();
|
||||
|
||||
if ($recentLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
$agent = new \Jenssegers\Agent\Agent();
|
||||
$agent->setUserAgent($userAgent);
|
||||
|
||||
$deviceType = 'desktop';
|
||||
if ($agent->isTablet()) {
|
||||
$deviceType = 'tablet';
|
||||
} elseif ($agent->isMobile()) {
|
||||
$deviceType = 'mobile';
|
||||
}
|
||||
|
||||
UserLoginLog::create([
|
||||
'user_id' => $event->user->id,
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $userAgent,
|
||||
'device_type' => $deviceType,
|
||||
'browser' => $agent->browser(),
|
||||
'platform' => $agent->platform(),
|
||||
'login_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Machine extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'location',
|
||||
'status',
|
||||
'temperature',
|
||||
'firmware_version',
|
||||
'last_heartbeat_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_heartbeat_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function logs()
|
||||
{
|
||||
return $this->hasMany(MachineLog::class);
|
||||
}
|
||||
|
||||
}
|
||||
23
app/Models/Machine/CoinInventory.php
Normal file
23
app/Models/Machine/CoinInventory.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CoinInventory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'machine_id',
|
||||
'denomination',
|
||||
'count',
|
||||
'type',
|
||||
];
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
}
|
||||
165
app/Models/Machine/Machine.php
Normal file
165
app/Models/Machine/Machine.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
use App\Traits\TenantScoped;
|
||||
|
||||
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',
|
||||
'firmware_version',
|
||||
'api_token',
|
||||
'last_heartbeat_at',
|
||||
'card_reader_seconds',
|
||||
'card_reader_checkout_time_1',
|
||||
'card_reader_checkout_time_2',
|
||||
'heating_start_time',
|
||||
'heating_end_time',
|
||||
'payment_buffer_seconds',
|
||||
'card_reader_no',
|
||||
'key_no',
|
||||
'invoice_status',
|
||||
'welcome_gift_enabled',
|
||||
'is_spring_slot_1_10',
|
||||
'is_spring_slot_11_20',
|
||||
'is_spring_slot_21_30',
|
||||
'is_spring_slot_31_40',
|
||||
'is_spring_slot_41_50',
|
||||
'is_spring_slot_51_60',
|
||||
'member_system_enabled',
|
||||
'payment_config_id',
|
||||
'machine_model_id',
|
||||
'images',
|
||||
'creator_id',
|
||||
'updater_id',
|
||||
];
|
||||
|
||||
protected $appends = ['image_urls'];
|
||||
|
||||
protected $casts = [
|
||||
'last_heartbeat_at' => 'datetime',
|
||||
'welcome_gift_enabled' => 'boolean',
|
||||
'is_spring_slot_1_10' => 'boolean',
|
||||
'is_spring_slot_11_20' => 'boolean',
|
||||
'is_spring_slot_21_30' => 'boolean',
|
||||
'is_spring_slot_31_40' => 'boolean',
|
||||
'is_spring_slot_41_50' => 'boolean',
|
||||
'is_spring_slot_51_60' => 'boolean',
|
||||
'member_system_enabled' => 'boolean',
|
||||
'images' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get machine images absolute URLs
|
||||
*/
|
||||
public function getImageUrlsAttribute(): array
|
||||
{
|
||||
if (empty($this->images)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn($path) => \Illuminate\Support\Facades\Storage::disk('public')->url($path), $this->images);
|
||||
}
|
||||
|
||||
public function logs()
|
||||
{
|
||||
return $this->hasMany(MachineLog::class);
|
||||
}
|
||||
|
||||
public function slots()
|
||||
{
|
||||
return $this->hasMany(MachineSlot::class);
|
||||
}
|
||||
|
||||
public function machineModel()
|
||||
{
|
||||
return $this->belongsTo(MachineModel::class);
|
||||
}
|
||||
|
||||
public function paymentConfig()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\PaymentConfig::class);
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class, 'creator_id');
|
||||
}
|
||||
|
||||
public function updater()
|
||||
{
|
||||
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',
|
||||
'60' => 'Purchasing',
|
||||
'61' => 'Locked Page',
|
||||
'62' => 'Dispense Failed',
|
||||
'301' => 'Slot Test',
|
||||
'302' => 'Slot Test',
|
||||
'401' => 'Payment Selection',
|
||||
'402' => 'Waiting for Payment',
|
||||
'403' => 'Dispensing',
|
||||
'404' => 'Receipt Printing',
|
||||
'601' => 'Pass Code',
|
||||
'602' => 'Pickup Code',
|
||||
'603' => 'Message Display',
|
||||
'604' => 'Cancel Purchase',
|
||||
'605' => 'Purchase Finished',
|
||||
'611' => 'Welcome Gift Status',
|
||||
'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);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class MachineLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
'level',
|
||||
'type',
|
||||
'message',
|
||||
'context',
|
||||
];
|
||||
37
app/Models/Machine/MachineModel.php
Normal file
37
app/Models/Machine/MachineModel.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Traits\TenantScoped;
|
||||
|
||||
class MachineModel extends Model
|
||||
{
|
||||
use TenantScoped;
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'company_id',
|
||||
'creator_id',
|
||||
'updater_id',
|
||||
];
|
||||
|
||||
public function machines()
|
||||
{
|
||||
return $this->hasMany(Machine::class);
|
||||
}
|
||||
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\Company::class);
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class, 'creator_id');
|
||||
}
|
||||
|
||||
public function updater()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class, 'updater_id');
|
||||
}
|
||||
}
|
||||
39
app/Models/Machine/MachineSlot.php
Normal file
39
app/Models/Machine/MachineSlot.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Models\Product\Product;
|
||||
|
||||
class MachineSlot extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'machine_id',
|
||||
'product_id',
|
||||
'slot_no',
|
||||
'max_stock',
|
||||
'stock',
|
||||
'expiry_date',
|
||||
'batch_no',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'last_restocked_at' => 'datetime',
|
||||
'expiry_date' => 'date:Y-m-d',
|
||||
];
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
45
app/Models/Machine/MaintenanceRecord.php
Normal file
45
app/Models/Machine/MaintenanceRecord.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?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',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'photos' => 'array',
|
||||
'maintenance_at' => 'datetime',
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
31
app/Models/Machine/RemoteCommand.php
Normal file
31
app/Models/Machine/RemoteCommand.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class RemoteCommand extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'machine_id',
|
||||
'command',
|
||||
'payload',
|
||||
'status',
|
||||
'response_payload',
|
||||
'executed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'response_payload' => 'array',
|
||||
'executed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
}
|
||||
28
app/Models/Machine/TimerStatus.php
Normal file
28
app/Models/Machine/TimerStatus.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TimerStatus extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'machine_id',
|
||||
'slot_no',
|
||||
'status',
|
||||
'remaining_seconds',
|
||||
'end_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'end_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
}
|
||||
60
app/Models/Member/DepositBonusRule.php
Normal file
60
app/Models/Member/DepositBonusRule.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DepositBonusRule extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'min_amount',
|
||||
'bonus_type',
|
||||
'bonus_value',
|
||||
'is_active',
|
||||
'start_at',
|
||||
'end_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'min_amount' => 'decimal:2',
|
||||
'bonus_value' => 'decimal:2',
|
||||
'is_active' => 'boolean',
|
||||
'start_at' => 'datetime',
|
||||
'end_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 取得目前有效的規則
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('start_at')->orWhere('start_at', '<=', now());
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('end_at')->orWhere('end_at', '>=', now());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算回饋金額
|
||||
*/
|
||||
public function calculateBonus(float $depositAmount): float
|
||||
{
|
||||
if ($depositAmount < $this->min_amount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($this->bonus_type === 'fixed') {
|
||||
return $this->bonus_value;
|
||||
}
|
||||
|
||||
// percentage
|
||||
return $depositAmount * ($this->bonus_value / 100);
|
||||
}
|
||||
}
|
||||
53
app/Models/Member/GiftDefinition.php
Normal file
53
app/Models/Member/GiftDefinition.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class GiftDefinition extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'type',
|
||||
'value',
|
||||
'tier_id',
|
||||
'trigger',
|
||||
'validity_days',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'value' => 'decimal:2',
|
||||
'validity_days' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 適用等級
|
||||
*/
|
||||
public function tier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MembershipTier::class, 'tier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 發放紀錄
|
||||
*/
|
||||
public function memberGifts(): HasMany
|
||||
{
|
||||
return $this->hasMany(MemberGift::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 有效禮品
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
}
|
||||
153
app/Models/Member/Member.php
Normal file
153
app/Models/Member/Member.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Member extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* 資料表名稱
|
||||
*/
|
||||
protected $table = 'members';
|
||||
|
||||
/**
|
||||
* 可批量賦值的屬性
|
||||
*/
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'email',
|
||||
'phone',
|
||||
'password',
|
||||
'birthday',
|
||||
'gender',
|
||||
'avatar',
|
||||
'is_active',
|
||||
'email_verified_at',
|
||||
'company_id',
|
||||
'barcode',
|
||||
'points',
|
||||
'wallet_balance',
|
||||
];
|
||||
|
||||
/**
|
||||
* 隱藏的屬性
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* 屬性轉換
|
||||
*/
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'birthday' => 'date',
|
||||
'is_active' => 'boolean',
|
||||
'password' => 'hashed',
|
||||
'points' => 'integer',
|
||||
'wallet_balance' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 建立時自動產生 UUID
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->uuid)) {
|
||||
$model->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯:社群帳號
|
||||
*/
|
||||
public function socialAccounts()
|
||||
{
|
||||
return $this->hasMany(SocialAccount::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯:錢包
|
||||
*/
|
||||
public function wallet()
|
||||
{
|
||||
return $this->hasOne(MemberWallet::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯:點數帳戶
|
||||
*/
|
||||
public function points()
|
||||
{
|
||||
return $this->hasOne(MemberPoint::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯:會員資格紀錄
|
||||
*/
|
||||
public function memberships()
|
||||
{
|
||||
return $this->hasMany(MemberMembership::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯:禮品紀錄
|
||||
*/
|
||||
public function gifts()
|
||||
{
|
||||
return $this->hasMany(MemberGift::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得目前有效的會員資格
|
||||
*/
|
||||
public function activeMembership()
|
||||
{
|
||||
return $this->hasOne(MemberMembership::class)->active()->latest('starts_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查是否已綁定指定社群
|
||||
*/
|
||||
public function hasSocialAccount(string $provider): bool
|
||||
{
|
||||
return $this->socialAccounts()->where('provider', $provider)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得或建立錢包
|
||||
*/
|
||||
public function getOrCreateWallet(): MemberWallet
|
||||
{
|
||||
return $this->wallet ?? $this->wallet()->create([
|
||||
'balance' => 0,
|
||||
'bonus_balance' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得或建立點數帳戶
|
||||
*/
|
||||
public function getOrCreatePoints(): MemberPoint
|
||||
{
|
||||
return $this->points ?? $this->points()->create([
|
||||
'available_points' => 0,
|
||||
'pending_points' => 0,
|
||||
'expired_points' => 0,
|
||||
'used_points' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
56
app/Models/Member/MemberGift.php
Normal file
56
app/Models/Member/MemberGift.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MemberGift extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'gift_definition_id',
|
||||
'status',
|
||||
'claimed_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'claimed_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬會員
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 禮品定義
|
||||
*/
|
||||
public function giftDefinition(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GiftDefinition::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 待領取的禮品
|
||||
*/
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', 'pending')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
}
|
||||
65
app/Models/Member/MemberMembership.php
Normal file
65
app/Models/Member/MemberMembership.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MemberMembership extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'tier_id',
|
||||
'starts_at',
|
||||
'expires_at',
|
||||
'payment_id',
|
||||
'auto_renew',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'starts_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'auto_renew' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬會員
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 會員等級
|
||||
*/
|
||||
public function tier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MembershipTier::class, 'tier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有效
|
||||
*/
|
||||
public function getIsActiveAttribute(): bool
|
||||
{
|
||||
return $this->status === 'active'
|
||||
&& (!$this->expires_at || $this->expires_at->isFuture());
|
||||
}
|
||||
|
||||
/**
|
||||
* 有效會員資格
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', 'active')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
}
|
||||
44
app/Models/Member/MemberPoint.php
Normal file
44
app/Models/Member/MemberPoint.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class MemberPoint extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'available_points',
|
||||
'pending_points',
|
||||
'expired_points',
|
||||
'used_points',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'available_points' => 'integer',
|
||||
'pending_points' => 'integer',
|
||||
'expired_points' => 'integer',
|
||||
'used_points' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬會員
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 點數異動紀錄
|
||||
*/
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(PointTransaction::class, 'member_id', 'member_id');
|
||||
}
|
||||
}
|
||||
48
app/Models/Member/MemberWallet.php
Normal file
48
app/Models/Member/MemberWallet.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class MemberWallet extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'balance',
|
||||
'bonus_balance',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'balance' => 'decimal:2',
|
||||
'bonus_balance' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬會員
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易紀錄
|
||||
*/
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(WalletTransaction::class, 'member_id', 'member_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 總餘額 (儲值 + 回饋)
|
||||
*/
|
||||
public function getTotalBalanceAttribute(): float
|
||||
{
|
||||
return $this->balance + $this->bonus_balance;
|
||||
}
|
||||
}
|
||||
62
app/Models/Member/MembershipTier.php
Normal file
62
app/Models/Member/MembershipTier.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class MembershipTier extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'annual_fee',
|
||||
'discount_rate',
|
||||
'point_multiplier',
|
||||
'description',
|
||||
'is_default',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'annual_fee' => 'decimal:2',
|
||||
'discount_rate' => 'decimal:2',
|
||||
'point_multiplier' => 'decimal:2',
|
||||
'is_default' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 此等級的會員紀錄
|
||||
*/
|
||||
public function memberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(MemberMembership::class, 'tier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 此等級的禮品定義
|
||||
*/
|
||||
public function giftDefinitions(): HasMany
|
||||
{
|
||||
return $this->hasMany(GiftDefinition::class, 'tier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得預設等級
|
||||
*/
|
||||
public static function getDefault(): ?self
|
||||
{
|
||||
return static::where('is_default', true)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否為免費等級
|
||||
*/
|
||||
public function getIsFreeAttribute(): bool
|
||||
{
|
||||
return $this->annual_fee <= 0;
|
||||
}
|
||||
}
|
||||
47
app/Models/Member/PointRule.php
Normal file
47
app/Models/Member/PointRule.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PointRule extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'trigger',
|
||||
'points_per_unit',
|
||||
'unit_amount',
|
||||
'validity_days',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'points_per_unit' => 'integer',
|
||||
'unit_amount' => 'decimal:2',
|
||||
'validity_days' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 取得有效規則
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據金額計算可獲得點數
|
||||
*/
|
||||
public function calculatePoints(float $amount): int
|
||||
{
|
||||
if ($this->unit_amount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) floor($amount / $this->unit_amount) * $this->points_per_unit;
|
||||
}
|
||||
}
|
||||
48
app/Models/Member/PointTransaction.php
Normal file
48
app/Models/Member/PointTransaction.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PointTransaction extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'type',
|
||||
'points',
|
||||
'balance_after',
|
||||
'description',
|
||||
'expires_at',
|
||||
'reference_type',
|
||||
'reference_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'points' => 'integer',
|
||||
'balance_after' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬會員
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已過期
|
||||
*/
|
||||
public function getIsExpiredAttribute(): bool
|
||||
{
|
||||
return $this->expires_at && $this->expires_at->isPast();
|
||||
}
|
||||
}
|
||||
53
app/Models/Member/SocialAccount.php
Normal file
53
app/Models/Member/SocialAccount.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SocialAccount extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* 資料表名稱
|
||||
*/
|
||||
protected $table = 'social_accounts';
|
||||
|
||||
/**
|
||||
* 可批量賦值的屬性
|
||||
*/
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'provider',
|
||||
'provider_id',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'profile_data',
|
||||
'token_expires_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* 屬性轉換
|
||||
*/
|
||||
protected $casts = [
|
||||
'profile_data' => 'array',
|
||||
'token_expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 隱藏的屬性
|
||||
*/
|
||||
protected $hidden = [
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* 關聯:會員
|
||||
*/
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
}
|
||||
38
app/Models/Member/WalletTransaction.php
Normal file
38
app/Models/Member/WalletTransaction.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WalletTransaction extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'type',
|
||||
'amount',
|
||||
'balance_after',
|
||||
'description',
|
||||
'reference_type',
|
||||
'reference_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'decimal:2',
|
||||
'balance_after' => 'decimal:2',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬會員
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
}
|
||||
40
app/Models/Product/Product.php
Normal file
40
app/Models/Product/Product.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Product;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use App\Traits\TenantScoped;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'category_id',
|
||||
'name',
|
||||
'sku',
|
||||
'barcode',
|
||||
'description',
|
||||
'price',
|
||||
'cost',
|
||||
'type',
|
||||
'image_url',
|
||||
'status',
|
||||
'name_dictionary_key',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'cost' => 'decimal:2',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(ProductCategory::class, 'category_id');
|
||||
}
|
||||
}
|
||||
24
app/Models/Product/ProductCategory.php
Normal file
24
app/Models/Product/ProductCategory.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Product;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use App\Traits\TenantScoped;
|
||||
|
||||
class ProductCategory extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'name',
|
||||
'name_dictionary_key',
|
||||
];
|
||||
|
||||
public function products()
|
||||
{
|
||||
return $this->hasMany(Product::class, 'category_id');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace App\Models\System;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
48
app/Models/System/Company.php
Normal file
48
app/Models/System/Company.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class Company extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'code',
|
||||
'tax_id',
|
||||
'contact_name',
|
||||
'contact_phone',
|
||||
'contact_email',
|
||||
'status',
|
||||
'valid_until',
|
||||
'note',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'valid_until' => 'date',
|
||||
'status' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the users for the company.
|
||||
*/
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the machines for the company.
|
||||
*/
|
||||
public function machines(): HasMany
|
||||
{
|
||||
return $this->hasMany(Machine::class);
|
||||
}
|
||||
}
|
||||
40
app/Models/System/PaymentConfig.php
Normal file
40
app/Models/System/PaymentConfig.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PaymentConfig extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'name',
|
||||
'settings',
|
||||
'creator_id',
|
||||
'updater_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'settings' => 'array',
|
||||
];
|
||||
|
||||
public function machines()
|
||||
{
|
||||
return $this->hasMany(\App\Models\Machine\Machine::class);
|
||||
}
|
||||
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\Company::class);
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'creator_id');
|
||||
}
|
||||
|
||||
public function updater()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updater_id');
|
||||
}
|
||||
}
|
||||
38
app/Models/System/Role.php
Normal file
38
app/Models/System/Role.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
use Spatie\Permission\Models\Role as SpatieRole;
|
||||
|
||||
class Role extends SpatieRole
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'guard_name',
|
||||
'company_id',
|
||||
'is_system',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_system' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the company that owns the role.
|
||||
*/
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include roles for a specific company or system roles.
|
||||
*/
|
||||
public function scopeForCompany($query, $company_id)
|
||||
{
|
||||
return $query->where(function($q) use ($company_id) {
|
||||
$q->where('company_id', $company_id)
|
||||
->orWhereNull('company_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
18
app/Models/System/Translation.php
Normal file
18
app/Models/System/Translation.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Translation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'group',
|
||||
'key',
|
||||
'locale',
|
||||
'value',
|
||||
];
|
||||
}
|
||||
108
app/Models/System/User.php
Normal file
108
app/Models/System/User.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
use App\Traits\TenantScoped;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable, HasRoles, TenantScoped, SoftDeletes;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'username',
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'phone',
|
||||
'avatar',
|
||||
'role',
|
||||
'status',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the login logs for the user.
|
||||
*/
|
||||
public function loginLogs()
|
||||
{
|
||||
return $this->hasMany(UserLoginLog::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the company that owns the user.
|
||||
*/
|
||||
public function company()
|
||||
{
|
||||
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.
|
||||
*/
|
||||
public function isSystemAdmin(): bool
|
||||
{
|
||||
return is_null($this->company_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user belongs to a tenant.
|
||||
*/
|
||||
public function isTenant(): bool
|
||||
{
|
||||
return !is_null($this->company_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL for the user's avatar.
|
||||
*/
|
||||
public function getAvatarUrlAttribute(): string
|
||||
{
|
||||
if ($this->avatar) {
|
||||
return \Illuminate\Support\Facades\Storage::disk('public')->url($this->avatar);
|
||||
}
|
||||
|
||||
// Return a default UI Avatar if no avatar is set
|
||||
return "https://ui-avatars.com/api/?name=" . urlencode($this->name) . "&color=7F9CF5&background=EBF4FF";
|
||||
}
|
||||
}
|
||||
30
app/Models/System/UserLoginLog.php
Normal file
30
app/Models/System/UserLoginLog.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class UserLoginLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'device_type',
|
||||
'browser',
|
||||
'platform',
|
||||
'login_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'login_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
50
app/Models/Transaction/DispenseRecord.php
Normal file
50
app/Models/Transaction/DispenseRecord.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Transaction;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Product\Product;
|
||||
|
||||
class DispenseRecord extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'order_id',
|
||||
'flow_id',
|
||||
'machine_id',
|
||||
'product_id',
|
||||
'slot_no',
|
||||
'amount',
|
||||
'remaining_stock',
|
||||
'dispense_status',
|
||||
'member_barcode',
|
||||
'machine_time',
|
||||
'points_used',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'decimal:2',
|
||||
'machine_time' => 'datetime',
|
||||
'dispense_status' => 'integer',
|
||||
];
|
||||
|
||||
public function order()
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
39
app/Models/Transaction/Invoice.php
Normal file
39
app/Models/Transaction/Invoice.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Transaction;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Invoice extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'order_id',
|
||||
'machine_id',
|
||||
'flow_id',
|
||||
'invoice_no',
|
||||
'amount',
|
||||
'carrier_id',
|
||||
'invoice_date',
|
||||
'random_number',
|
||||
'love_code',
|
||||
'rtn_code',
|
||||
'rtn_msg',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'total_amount' => 'decimal:2',
|
||||
'tax_amount' => 'decimal:2',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function order()
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
}
|
||||
64
app/Models/Transaction/Order.php
Normal file
64
app/Models/Transaction/Order.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Transaction;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use App\Traits\TenantScoped;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Member\Member;
|
||||
|
||||
class Order extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'flow_id',
|
||||
'order_no',
|
||||
'machine_id',
|
||||
'member_id',
|
||||
'total_amount',
|
||||
'discount_amount',
|
||||
'pay_amount',
|
||||
'payment_type',
|
||||
'payment_status',
|
||||
'payment_at',
|
||||
'status',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'total_amount' => 'decimal:2',
|
||||
'discount_amount' => 'decimal:2',
|
||||
'pay_amount' => 'decimal:2',
|
||||
'payment_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
}
|
||||
|
||||
public function invoice()
|
||||
{
|
||||
return $this->hasOne(Invoice::class);
|
||||
}
|
||||
|
||||
public function dispenseRecords()
|
||||
{
|
||||
return $this->hasMany(DispenseRecord::class);
|
||||
}
|
||||
}
|
||||
39
app/Models/Transaction/OrderItem.php
Normal file
39
app/Models/Transaction/OrderItem.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Transaction;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Models\Product\Product;
|
||||
|
||||
class OrderItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'order_id',
|
||||
'product_id',
|
||||
'product_name',
|
||||
'sku',
|
||||
'price',
|
||||
'quantity',
|
||||
'subtotal',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'subtotal' => 'decimal:2',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function order()
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
23
app/Models/Transaction/PaymentType.php
Normal file
23
app/Models/Transaction/PaymentType.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Transaction;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class PaymentType extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'code',
|
||||
'config',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'config' => 'array',
|
||||
];
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'phone',
|
||||
'avatar',
|
||||
'role',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
66
app/Policies/Machine/MaintenanceRecordPolicy.php
Normal file
66
app/Policies/Machine/MaintenanceRecordPolicy.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies\Machine;
|
||||
|
||||
use App\Models\Machine\MaintenanceRecord;
|
||||
use App\Models\System\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class MaintenanceRecordPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return $user->isSystemAdmin() && $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\LogSuccessfulLogin;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -19,6 +22,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
if (str_starts_with(config('app.url'), 'https://')) {
|
||||
\Illuminate\Support\Facades\URL::forceScheme('https');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\LogSuccessfulLogin;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
@@ -18,6 +20,9 @@ class EventServiceProvider extends ServiceProvider
|
||||
Registered::class => [
|
||||
SendEmailVerificationNotification::class,
|
||||
],
|
||||
Login::class => [
|
||||
LogSuccessfulLogin::class,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
275
app/Services/Machine/MachineService.php
Normal file
275
app/Services/Machine/MachineService.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Machine;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineLog;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class MachineService
|
||||
{
|
||||
/**
|
||||
* Update machine heartbeat and status.
|
||||
*
|
||||
* @param string $serialNo
|
||||
* @param array $data
|
||||
* @return Machine
|
||||
*/
|
||||
public function updateHeartbeat(string $serialNo, array $data): Machine
|
||||
{
|
||||
return DB::transaction(function () use ($serialNo, $data) {
|
||||
$machine = Machine::where('serial_no', $serialNo)->firstOrFail();
|
||||
|
||||
// 參數相容性處理 (Mapping legacy fields to new fields)
|
||||
$temperature = $data['temperature'] ?? $machine->temperature;
|
||||
$currentPage = $data['current_page'] ?? $data['M_Stus2'] ?? $machine->current_page;
|
||||
$doorStatus = $data['door_status'] ?? $data['door'] ?? $machine->door_status;
|
||||
$firmwareVersion = $data['firmware_version'] ?? $data['M_Ver'] ?? $machine->firmware_version;
|
||||
$model = $data['model'] ?? $data['M_Stus'] ?? $machine->model;
|
||||
|
||||
$updateData = [
|
||||
'status' => 'online',
|
||||
'temperature' => $temperature,
|
||||
'current_page' => $currentPage,
|
||||
'door_status' => $doorStatus,
|
||||
'firmware_version' => $firmwareVersion,
|
||||
'model' => $model,
|
||||
'last_heartbeat_at' => now(),
|
||||
];
|
||||
|
||||
$machine->update($updateData);
|
||||
|
||||
// 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'],
|
||||
'context' => $data['log_payload'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $machine;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync machine slots based on replenishment report.
|
||||
*
|
||||
* @param Machine $machine
|
||||
* @param array $slotsData
|
||||
*/
|
||||
public function syncSlots(Machine $machine, array $slotsData): void
|
||||
{
|
||||
DB::transaction(function () use ($machine, $slotsData) {
|
||||
foreach ($slotsData as $slotData) {
|
||||
$slotNo = $slotData['slot_no'] ?? null;
|
||||
if (!$slotNo) continue;
|
||||
|
||||
$existingSlot = $machine->slots()->where('slot_no', $slotNo)->first();
|
||||
|
||||
$updateData = [
|
||||
'product_id' => $slotData['product_id'] ?? null,
|
||||
'stock' => $slotData['stock'] ?? 0,
|
||||
'capacity' => $slotData['capacity'] ?? ($existingSlot->capacity ?? 10),
|
||||
'price' => $slotData['price'] ?? ($existingSlot->price ?? 0),
|
||||
'last_restocked_at' => now(),
|
||||
];
|
||||
|
||||
// 如果商品變了,或者這是一次明確的補貨回報,清空效期等待管理員更新
|
||||
// 這裡我們暫定只要有 report 進來,就需要重新確認效期
|
||||
$updateData['expiry_date'] = null;
|
||||
|
||||
if ($existingSlot) {
|
||||
$existingSlot->update($updateData);
|
||||
} else {
|
||||
$machine->slots()->create(array_merge($updateData, ['slot_no' => $slotNo]));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update machine slot stock (single slot).
|
||||
* Legacy support for recordLog (Existing code).
|
||||
*/
|
||||
public function recordLog(int $machineId, array $data): MachineLog
|
||||
{
|
||||
$machine = Machine::findOrFail($machineId);
|
||||
|
||||
return $machine->logs()->create([
|
||||
'level' => $data['level'] ?? 'info',
|
||||
'message' => $data['message'],
|
||||
'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 current status)
|
||||
$machines = Machine::all(); // This is filtered by TenantScoped
|
||||
$totalMachines = $machines->count();
|
||||
$onlineCount = $machines->where('status', '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();
|
||||
}
|
||||
}
|
||||
120
app/Services/Transaction/TransactionService.php
Normal file
120
app/Services/Transaction/TransactionService.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Transaction;
|
||||
|
||||
use App\Models\Transaction\Order;
|
||||
use App\Models\Transaction\OrderItem;
|
||||
use App\Models\Transaction\Invoice;
|
||||
use App\Models\Transaction\DispenseRecord;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\Machine\Machine;
|
||||
|
||||
class TransactionService
|
||||
{
|
||||
/**
|
||||
* Process a new transaction (B600).
|
||||
*/
|
||||
public function processTransaction(array $data): Order
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
|
||||
|
||||
// Create Order
|
||||
$order = Order::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'flow_id' => $data['flow_id'] ?? null,
|
||||
'order_no' => $data['order_no'] ?? $this->generateOrderNo(),
|
||||
'machine_id' => $machine->id,
|
||||
'member_id' => $data['member_id'] ?? null,
|
||||
'total_amount' => $data['total_amount'],
|
||||
'discount_amount' => $data['discount_amount'] ?? 0,
|
||||
'pay_amount' => $data['pay_amount'],
|
||||
'payment_type' => $data['payment_type'] ?? 0,
|
||||
'payment_status' => $data['payment_status'] ?? 1,
|
||||
'payment_at' => now(),
|
||||
'status' => 'completed',
|
||||
'metadata' => $data['metadata'] ?? null,
|
||||
]);
|
||||
|
||||
// Create Order Items
|
||||
if (!empty($data['items'])) {
|
||||
foreach ($data['items'] as $item) {
|
||||
$order->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'product_name' => $item['product_name'] ?? 'Unknown',
|
||||
'sku' => $item['sku'] ?? null,
|
||||
'price' => $item['price'],
|
||||
'quantity' => $item['quantity'],
|
||||
'subtotal' => $item['price'] * $item['quantity'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique order number.
|
||||
*/
|
||||
protected function generateOrderNo(): string
|
||||
{
|
||||
return 'ORD-' . now()->format('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record Invoice (B601).
|
||||
*/
|
||||
public function recordInvoice(array $data): Invoice
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
|
||||
|
||||
$order = null;
|
||||
if (!empty($data['flow_id'])) {
|
||||
$order = Order::where('flow_id', $data['flow_id'])->first();
|
||||
}
|
||||
|
||||
return Invoice::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
|
||||
'machine_id' => $machine->id,
|
||||
'flow_id' => $data['flow_id'] ?? null,
|
||||
'invoice_no' => $data['invoice_no'] ?? null,
|
||||
'amount' => $data['amount'] ?? 0,
|
||||
'carrier_id' => $data['carrier_id'] ?? null,
|
||||
'invoice_date' => $data['invoice_date'] ?? null,
|
||||
'random_number' => $data['random_no'] ?? null,
|
||||
'love_code' => $data['love_code'] ?? null,
|
||||
'metadata' => $data['metadata'] ?? null,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record dispense result (B602).
|
||||
*/
|
||||
public function recordDispense(array $data): DispenseRecord
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
|
||||
|
||||
$order = null;
|
||||
if (!empty($data['flow_id'])) {
|
||||
$order = Order::where('flow_id', $data['flow_id'])->first();
|
||||
}
|
||||
|
||||
return DispenseRecord::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
|
||||
'flow_id' => $data['flow_id'] ?? null,
|
||||
'machine_id' => $machine->id,
|
||||
'slot_no' => $data['slot_no'] ?? 'unknown',
|
||||
'product_id' => $data['product_id'] ?? null,
|
||||
'amount' => $data['amount'] ?? 0,
|
||||
'dispense_status' => $data['dispense_status'] ?? 0,
|
||||
'machine_time' => $data['machine_time'] ?? now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user