Compare commits
69 Commits
main
...
eb73def5f8
| Author | SHA1 | Date | |
|---|---|---|---|
| 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`,以避免連線至錯誤的服務環境。
|
||||
70
.agents/rules/rbac-rules.md
Normal file
70
.agents/rules/rbac-rules.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
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;`
|
||||
|
||||
---
|
||||
|
||||
## 2. 權限開發規範 (spatie/laravel-permission)
|
||||
|
||||
### 2.1 租戶感知角色 (Tenant-Aware Roles)
|
||||
- `roles` 資料表已擴充 `company_id` 欄位。
|
||||
- 撈取角色清單供指派時,必須過濾 `company_id` 或為 null 的系統預設角色。
|
||||
|
||||
### 2.2 權限命名
|
||||
- 權限名稱應遵循 `[module].[action]` 格式(例如 `machine.view`, `machine.edit`)。
|
||||
- 所有租戶共用相同的權限定義。
|
||||
|
||||
---
|
||||
|
||||
## 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 角色權限維護
|
||||
- 初始建立後,該租戶的「管理員」角色即成為獨立資源,可由具有權限的帳號進行細部調整。
|
||||
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 層級進行了基礎的參數驗證?
|
||||
306
.agents/skills/ui-minimal-luxury/SKILL.md
Normal file
306
.agents/skills/ui-minimal-luxury/SKILL.md
Normal file
@@ -0,0 +1,306 @@
|
||||
---
|
||||
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)
|
||||
|
||||
- [ ] **列表佈局**: 是否採用「整合式卡片」結構且內距設為 `p-8`?
|
||||
- [ ] **分頁與總數**: 列表底部是否正確召喚 `vendor.pagination.luxury`?
|
||||
- [ ] **文字色階**: 符合標題 `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 italic">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`,因為佈局基底已提供基礎間距。僅使用 `space-y-6` (或 `space-y-8`) 控制區塊間隙。
|
||||
2. **主容器樣式**: 強制對齊為 `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>
|
||||
```
|
||||
|
||||
## 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` 且子元素套用 `group-hover:bg-slate-50/80` (深色: `dark:group-hover:bg-slate-800/40`) 以提供高級的互動感知。
|
||||
- **圖示容器懸停 (Icon Hover Palette)**:
|
||||
- 列表左側的主圖示容器在 `group-hover` 時,應由淡色背景轉為 **實體主題色**。
|
||||
- 類別: `group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300`。
|
||||
- **文字同步變色**:
|
||||
- 主標題文字在 `group-hover` 時應同步變色,以強化點擊引導。
|
||||
- 類別: `group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors`。
|
||||
|
||||
### 分頁與列表控制項 (Pagination & Controls)
|
||||
為了維持操作一致性,所有列表的分頁與切換組件必須遵循以下「Luxury Jump」模式:
|
||||
- **統一高度**: 所有控制項(按鈕、下拉選單)固定為 `h-9` (36px)。
|
||||
- **筆數切換 (Limit Selector)**:
|
||||
- 規範: **禁止**在表格上方(Header/Toolbar)重複放置筆數切換選單。統一收納於底部分頁欄位。
|
||||
- **分頁導航 (Luxury Jump)**:
|
||||
- 模式: 捨棄傳統頁碼按鈕,全端統一使用「跳轉選單」。
|
||||
- 寬度: 下拉選單內部 Padding 為 `pl-4 pr-10`。
|
||||
- 字體: 使用 `text-xs font-black tracking-widest`。
|
||||
- **指示文字**:
|
||||
- 行動端隱藏多餘詞彙,僅保留「1 - 10 / 50」格式。
|
||||
- 數字顏色對齊 `text-slate-600` (深色: `text-slate-300`)。
|
||||
|
||||
### 底部清單控制項 (Bottom List Controls)
|
||||
為了確保長列表的操作便利,清單底部應保持乾淨,統一由分頁與總數組件接管操作。
|
||||
|
||||
### 標準操作按鈕 (Standard Action Icons)
|
||||
表格內的操作欄位(如「編輯」、「刪除」、「詳情」)必須使用以下定義之 **「黃金標準 (Gold Standard)」**:
|
||||
|
||||
- **共同樣式**:
|
||||
- 容器: `p-2 rounded-lg bg-slate-50 dark:bg-slate-800`
|
||||
- 主色: `text-slate-400`
|
||||
- 邊框: `border border-transparent` (防閃爍處理)
|
||||
- 過渡: `transition-all` (使用預設速度以確保俐落感)
|
||||
- 圖示粗細: `stroke-width="2.5"`
|
||||
- 尺寸: `w-4 h-4`
|
||||
|
||||
- **編輯按鈕 (Edit)**:
|
||||
- 懸停特效: `hover:text-cyan-500 hover:bg-cyan-500/10 hover:border-cyan-500/20`
|
||||
- SVG 路徑:
|
||||
```html
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
||||
```
|
||||
|
||||
- **查看詳情 (View/Detail)**:
|
||||
- 懸停特效: `hover:text-indigo-500 hover:bg-indigo-500/10 hover:border-indigo-500/20`
|
||||
- SVG 路徑:
|
||||
```html
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"/></svg>
|
||||
```
|
||||
|
||||
- **刪除按鈕 (Delete)**:
|
||||
- 懸停特效: `hover:text-rose-500 hover:bg-rose-500/10 hover:border-rose-500/20`
|
||||
- SVG 路徑:
|
||||
```html
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
|
||||
```
|
||||
|
||||
## 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` 來建立明確的「標籤感」,而非僅僅是「微縮文字」。
|
||||
|
||||
---
|
||||
> [!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,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\BasicSettings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MachinePhotoController extends Controller
|
||||
{
|
||||
/**
|
||||
* 更新機台照片
|
||||
*/
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 將圖片轉換為 WebP 並儲存
|
||||
*/
|
||||
protected function storeAsWebp($file, $directory): string
|
||||
{
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$filename = uniqid() . '.webp';
|
||||
$path = "{$directory}/{$filename}";
|
||||
|
||||
// 讀取原始圖片
|
||||
$imageType = exif_imagetype($file->getRealPath());
|
||||
switch ($imageType) {
|
||||
case IMAGETYPE_JPEG:
|
||||
$source = imagecreatefromjpeg($file->getRealPath());
|
||||
break;
|
||||
case IMAGETYPE_PNG:
|
||||
$source = imagecreatefrompng($file->getRealPath());
|
||||
break;
|
||||
case IMAGETYPE_WEBP:
|
||||
$source = imagecreatefromwebp($file->getRealPath());
|
||||
break;
|
||||
default:
|
||||
// 如果格式不支援,直接存
|
||||
return $file->storeAs($directory, $file->hashName(), 'public');
|
||||
}
|
||||
|
||||
if (!$source) {
|
||||
return $file->storeAs($directory, $file->hashName(), 'public');
|
||||
}
|
||||
|
||||
// 確保支援真彩色(解決 palette image 問題)
|
||||
if (!imageistruecolor($source)) {
|
||||
imagepalettetotruecolor($source);
|
||||
}
|
||||
|
||||
// 捕捉輸出
|
||||
ob_start();
|
||||
imagewebp($source, null, 80);
|
||||
$content = ob_get_clean();
|
||||
imagedestroy($source);
|
||||
|
||||
Storage::disk('public')->put($path, $content);
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<?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 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
|
||||
{
|
||||
/**
|
||||
* 顯示機台與型號設定列表 (採用標籤頁整合)
|
||||
*/
|
||||
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')->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->processAndStoreImage($image);
|
||||
}
|
||||
}
|
||||
|
||||
$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')->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',
|
||||
]);
|
||||
|
||||
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->processAndStoreImage($file);
|
||||
$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.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 處理並儲存圖片 (轉換為 WebP 並調整大小)
|
||||
*/
|
||||
protected function processAndStoreImage($file)
|
||||
{
|
||||
$path = 'machines/' . \Illuminate\Support\Str::random(40) . '.webp';
|
||||
|
||||
// 載入原圖
|
||||
$imageInfo = getimagesize($file->getRealPath());
|
||||
$mime = $imageInfo['mime'];
|
||||
|
||||
switch ($mime) {
|
||||
case 'image/jpeg':
|
||||
$image = imagecreatefromjpeg($file->getRealPath());
|
||||
break;
|
||||
case 'image/png':
|
||||
$image = imagecreatefrompng($file->getRealPath());
|
||||
break;
|
||||
case 'image/gif':
|
||||
$image = imagecreatefromgif($file->getRealPath());
|
||||
break;
|
||||
default:
|
||||
return $file->store('machines', 'public');
|
||||
}
|
||||
|
||||
if ($image) {
|
||||
// [修正] imagewebp(): Palette image not supported by webp
|
||||
// 若為 Palette 圖片 (例如 GIF),轉換為 Truecolor
|
||||
if (!imageistruecolor($image)) {
|
||||
imagepalettetotruecolor($image);
|
||||
}
|
||||
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->makeDirectory('machines');
|
||||
$fullPath = \Illuminate\Support\Facades\Storage::disk('public')->path($path);
|
||||
|
||||
// 轉換並儲存 (品質 80)
|
||||
imagewebp($image, $fullPath, 80);
|
||||
imagedestroy($image);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
return $file->store('machines', 'public');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?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()
|
||||
->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')->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();
|
||||
|
||||
// 取得可供選擇的客戶角色範本 (is_system = 0, company_id = null)
|
||||
$template_roles = \App\Models\System\Role::where('is_system', 0)
|
||||
->whereNull('company_id')
|
||||
->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 = '管理員';
|
||||
|
||||
$template_role = \App\Models\System\Role::where('name', $selected_role_name)
|
||||
->whereNull('company_id')
|
||||
->where('is_system', 0)
|
||||
->first();
|
||||
|
||||
if ($template_role) {
|
||||
// 克隆範本為該公司的「管理員」
|
||||
$clonedRole = \App\Models\System\Role::query()->create([
|
||||
'name' => '管理員',
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $company->id,
|
||||
'is_system' => false,
|
||||
]);
|
||||
$clonedRole->syncPermissions($template_role->permissions);
|
||||
} 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,100 @@
|
||||
|
||||
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);
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$query = Machine::query();
|
||||
|
||||
// 搜尋:名稱或序號
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$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'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
public function logs(Request $request): View
|
||||
{
|
||||
return view('admin.machines.edit', compact('machine'));
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$logs = \App\Models\Machine\MachineLog::with('machine')
|
||||
->when($request->level, function ($query, $level) {
|
||||
return $query->where('level', $level);
|
||||
})
|
||||
->when($request->machine_id, function ($query, $machineId) {
|
||||
return $query->where('machine_id', $machineId);
|
||||
})
|
||||
->latest()
|
||||
->paginate($per_page)->withQueryString();
|
||||
|
||||
$machines = Machine::select('id', 'name')->get();
|
||||
|
||||
return view('admin.machines.logs', compact('logs', 'machines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
* 機台權限設定 (開發中)
|
||||
*/
|
||||
public function update(Request $request, Machine $machine)
|
||||
public function permissions(Request $request): View
|
||||
{
|
||||
$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->update($validated);
|
||||
|
||||
return redirect()->route('admin.machines.index')
|
||||
->with('success', '機台更新成功');
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
* 機台使用率統計 (開發中)
|
||||
*/
|
||||
public function destroy(Machine $machine)
|
||||
public function utilization(Request $request): View
|
||||
{
|
||||
$machine->delete();
|
||||
|
||||
return redirect()->route('admin.machines.index')
|
||||
->with('success', '機台已刪除');
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
}
|
||||
|
||||
// 機台日誌
|
||||
public function logs()
|
||||
/**
|
||||
* 機台到期管理 (開發中)
|
||||
*/
|
||||
public function expiry(Request $request): View
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '機台日誌',
|
||||
'description' => '機台操作歷史紀錄回溯',
|
||||
'features' => [
|
||||
'操作時間戳記',
|
||||
'事件類型分類',
|
||||
'操作人員記錄',
|
||||
'詳細描述查詢',
|
||||
]
|
||||
]);
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
}
|
||||
|
||||
// 機台權限
|
||||
public function permissions()
|
||||
/**
|
||||
* 機台維護紀錄 (開發中)
|
||||
*/
|
||||
public function maintenance(Request $request): View
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '機台權限',
|
||||
'description' => '機台存取權限控管',
|
||||
]);
|
||||
}
|
||||
|
||||
// 機台稼動率
|
||||
public function utilization()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '機台稼動率',
|
||||
'description' => '機台運行效率分析',
|
||||
]);
|
||||
}
|
||||
|
||||
// 效期管理
|
||||
public function expiry()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '效期管理',
|
||||
'description' => '商品效期與貨道出貨控制',
|
||||
]);
|
||||
}
|
||||
|
||||
// 維修管理單
|
||||
public function maintenance()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '維修管理單',
|
||||
'description' => '機台維修工單系統',
|
||||
]);
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
}
|
||||
}
|
||||
|
||||
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,418 @@ 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_id is null)
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$query->where('company_id', $user->company_id);
|
||||
}
|
||||
|
||||
// 搜尋:角色名稱
|
||||
if ($search = request()->input('search')) {
|
||||
$query->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
$roles = $query->latest()->paginate($per_page)->withQueryString();
|
||||
$all_permissions = \Spatie\Permission\Models\Permission::all()
|
||||
->filter(function($perm) {
|
||||
// 排除子項目的權限,只顯示主選單權限
|
||||
$excluded = [
|
||||
'menu.basic.machines',
|
||||
'menu.basic.payment-configs',
|
||||
'menu.companies',
|
||||
'menu.accounts',
|
||||
'menu.roles',
|
||||
];
|
||||
return !in_array($perm->name, $excluded);
|
||||
})
|
||||
->groupBy(function($perm) {
|
||||
if (str_starts_with($perm->name, 'menu.')) {
|
||||
return 'menu';
|
||||
}
|
||||
return 'other';
|
||||
});
|
||||
|
||||
// 根據路由決定標題
|
||||
$title = request()->routeIs('*.sub-account-roles') ? __('Sub Account Roles') : __('Role Settings');
|
||||
|
||||
return view('admin.permission.roles', compact('roles', 'all_permissions', 'title'));
|
||||
}
|
||||
|
||||
// 其他功能管理
|
||||
public function others()
|
||||
/**
|
||||
* Store a newly created role in storage.
|
||||
*/
|
||||
public function storeRole(Request $request)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '其他功能管理',
|
||||
'description' => '其他特殊功能權限',
|
||||
$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 (!$is_system) {
|
||||
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']);
|
||||
}
|
||||
$role->syncPermissions($perms);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Role created successfully.'));
|
||||
}
|
||||
|
||||
// AI智能預測
|
||||
public function aiPrediction()
|
||||
/**
|
||||
* Update the specified role in storage.
|
||||
*/
|
||||
public function updateRole(Request $request, $id)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => 'AI智能預測',
|
||||
'description' => 'AI功能權限設定',
|
||||
$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 (!$is_system) {
|
||||
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']);
|
||||
}
|
||||
$role->syncPermissions($perms);
|
||||
|
||||
return redirect()->back()->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']);
|
||||
|
||||
// 租戶隔離:如果不是系統管理員,則只看自己公司的成員
|
||||
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::where('name', '!=', 'super-admin');
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$roles_query->where('company_id', auth()->user()->company_id);
|
||||
}
|
||||
$roles = $roles_query->get();
|
||||
|
||||
// 根據路由決定標題
|
||||
$title = request()->routeIs('*.sub-accounts') ? __('Sub Account Management') : __('Account Management');
|
||||
|
||||
return view('admin.data-config.accounts', compact('users', 'companies', 'roles', 'title'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' => 'required|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) {
|
||||
// 如果是租戶帳號,不能選各項系統角色 (is_system = 1)
|
||||
if ($role->is_system) {
|
||||
return redirect()->back()->with('error', __('System roles 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 在幫空白公司開帳號時觸發)
|
||||
$role_to_assign = $validated['role'];
|
||||
$company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
|
||||
|
||||
if ($company_id && $role && !$role->is_system && $role->company_id === null) {
|
||||
// 檢查該公司是否已有名為「管理員」的角色
|
||||
$existingRole = \App\Models\System\Role::where('company_id', $company_id)
|
||||
->where('name', '管理員')
|
||||
->first();
|
||||
|
||||
if (!$existingRole) {
|
||||
// 克隆範本為該公司的「管理員」
|
||||
$clonedRole = \App\Models\System\Role::query()->create([
|
||||
'name' => '管理員',
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $company_id,
|
||||
'is_system' => false,
|
||||
]);
|
||||
$clonedRole->syncPermissions($role->permissions);
|
||||
$role_to_assign = '管理員';
|
||||
} else {
|
||||
// 如果已存在名為「管理員」的角色,則直接使用它
|
||||
$role_to_assign = '管理員';
|
||||
}
|
||||
}
|
||||
|
||||
$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'],
|
||||
]);
|
||||
|
||||
$user->assignRole($role_to_assign);
|
||||
|
||||
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' => 'required|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) {
|
||||
return redirect()->back()->with('error', __('System roles 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'],
|
||||
];
|
||||
|
||||
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']);
|
||||
}
|
||||
|
||||
// 角色初始化與克隆邏輯
|
||||
$role_to_assign = $validated['role'];
|
||||
$target_company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
|
||||
|
||||
if ($target_company_id && $roleObj && !$roleObj->is_system && $roleObj->company_id === null) {
|
||||
// 檢查該公司是否已有名為「管理員」的角色
|
||||
$existingRole = \App\Models\System\Role::where('company_id', $target_company_id)
|
||||
->where('name', '管理員')
|
||||
->first();
|
||||
|
||||
if (!$existingRole) {
|
||||
$clonedRole = \App\Models\System\Role::query()->create([
|
||||
'name' => '管理員',
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $target_company_id,
|
||||
'is_system' => false,
|
||||
]);
|
||||
$clonedRole->syncPermissions($roleObj->permissions);
|
||||
$role_to_assign = '管理員';
|
||||
} else {
|
||||
$role_to_assign = '管理員';
|
||||
}
|
||||
}
|
||||
|
||||
$user->update($updateData);
|
||||
|
||||
// 如果是編輯自己且原本是超級管理員,強制保留 super-admin 角色
|
||||
if ($user->id === auth()->id() && auth()->user()->isSystemAdmin()) {
|
||||
$user->syncRoles(['super-admin']);
|
||||
} else {
|
||||
$user->syncRoles([$role_to_assign]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
123
app/Http/Controllers/Api/V1/App/MachineController.php
Normal file
123
app/Http/Controllers/Api/V1/App/MachineController.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
104
app/Models/Machine/Machine.php
Normal file
104
app/Models/Machine/Machine.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?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 $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 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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
<?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 = [
|
||||
'machine_id',
|
||||
'level',
|
||||
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',
|
||||
'slot_name',
|
||||
'capacity',
|
||||
'stock',
|
||||
'price',
|
||||
'status',
|
||||
'last_restocked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'last_restocked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::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');
|
||||
}
|
||||
}
|
||||
34
app/Models/System/Role.php
Normal file
34
app/Models/System/Role.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?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',
|
||||
];
|
||||
|
||||
/**
|
||||
* 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',
|
||||
];
|
||||
}
|
||||
100
app/Models/System/User.php
Normal file
100
app/Models/System/User.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
];
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
72
app/Services/Machine/MachineService.php
Normal file
72
app/Services/Machine/MachineService.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?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();
|
||||
|
||||
$updateData = [
|
||||
'status' => 'online',
|
||||
'temperature' => $data['temperature'] ?? $machine->temperature,
|
||||
'current_page' => $data['current_page'] ?? $machine->current_page,
|
||||
'door_status' => $data['door_status'] ?? $machine->door_status,
|
||||
'firmware_version' => $data['firmware_version'] ?? $machine->firmware_version,
|
||||
'last_heartbeat_at' => now(),
|
||||
];
|
||||
|
||||
$machine->update($updateData);
|
||||
|
||||
// Record log if provided
|
||||
if (!empty($data['log'])) {
|
||||
$machine->logs()->create([
|
||||
'level' => $data['log_level'] ?? 'info',
|
||||
'message' => $data['log'],
|
||||
'payload' => $data['log_payload'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $machine;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update machine slot stock.
|
||||
*/
|
||||
public function updateSlotStock(Machine $machine, int $slotNo, int $stock): void
|
||||
{
|
||||
$machine->slots()->where('slot_no', $slotNo)->update([
|
||||
'stock' => $stock,
|
||||
'last_restocked_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'],
|
||||
'payload' => $data['context'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
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(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
49
app/Traits/ApiResponse.php
Normal file
49
app/Traits/ApiResponse.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
trait ApiResponse
|
||||
{
|
||||
/**
|
||||
* 回傳成功的回應
|
||||
*
|
||||
* @param mixed $data
|
||||
* @param string $message
|
||||
* @param int $code
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function successResponse($data = [], string $message = 'OK', int $code = 200): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'data' => empty($data) ? new \stdClass() : $data, // 確保前端收到的是 Object 而非 Empty Array
|
||||
], $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回傳錯誤的回應
|
||||
*
|
||||
* @param string $message
|
||||
* @param int $code
|
||||
* @param mixed $errors
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function errorResponse(string $message, int $code = 400, $errors = null): JsonResponse
|
||||
{
|
||||
$response = [
|
||||
'success' => false,
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
if (!is_null($errors)) {
|
||||
$response['errors'] = $errors;
|
||||
}
|
||||
|
||||
return response()->json($response, $code);
|
||||
}
|
||||
}
|
||||
51
app/Traits/TenantScoped.php
Normal file
51
app/Traits/TenantScoped.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait TenantScoped
|
||||
{
|
||||
/**
|
||||
* Boot the trait.
|
||||
*/
|
||||
public static function bootTenantScoped(): void
|
||||
{
|
||||
static::addGlobalScope('tenant', function (Builder $query) {
|
||||
// 避免在 User Model 本身套用此 Scope,否則在 auth()->user() 讀取 User 時會產生循環引用
|
||||
if (static::class === \App\Models\System\User::class) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if running in console/migration
|
||||
if (app()->runningInConsole()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
// 如果使用者已登入且有綁定公司,則自動注入過濾條件
|
||||
if ($user && $user->company_id) {
|
||||
$query->where((new static)->getTable() . '.company_id', $user->company_id);
|
||||
}
|
||||
});
|
||||
|
||||
// 建立資料時,自動填入當前使用者的 company_id
|
||||
static::creating(function ($model) {
|
||||
if (!$model->company_id) {
|
||||
$user = auth()->user();
|
||||
if ($user && $user->company_id) {
|
||||
$model->company_id = $user->company_id;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the company relationship.
|
||||
*/
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\Company::class);
|
||||
}
|
||||
}
|
||||
49
artisan
49
artisan
@@ -1,53 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Register The Auto Loader
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Composer provides a convenient, automatically generated class loader
|
||||
| for our application. We just need to utilize it! We'll require it
|
||||
| into the script here so that we do not have to worry about the
|
||||
| loading of any of our classes manually. It's great to relax.
|
||||
|
|
||||
*/
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Run The Artisan Application
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When we run the console application, the current CLI command will be
|
||||
| executed in this console and the response sent back to a terminal
|
||||
| or another output device for the developers. Here goes nothing!
|
||||
|
|
||||
*/
|
||||
|
||||
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||
|
||||
$status = $kernel->handle(
|
||||
$input = new Symfony\Component\Console\Input\ArgvInput,
|
||||
new Symfony\Component\Console\Output\ConsoleOutput
|
||||
);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Shutdown The Application
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Once Artisan has finished running, we will fire off the shutdown events
|
||||
| so that any final work may be done by the application before we shut
|
||||
| down the process. This is the last thing to happen to the request.
|
||||
|
|
||||
*/
|
||||
|
||||
$kernel->terminate($input, $status);
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
|
||||
16
compose.yaml
16
compose.yaml
@@ -6,12 +6,12 @@ services:
|
||||
args:
|
||||
WWWGROUP: '${WWWGROUP}'
|
||||
image: 'sail-8.5/app'
|
||||
container_name: start-cloud-laravel
|
||||
hostname: start-cloud-laravel
|
||||
container_name: star-cloud-laravel
|
||||
hostname: star-cloud-laravel
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
ports:
|
||||
- '${APP_PORT:-80}:80'
|
||||
- '${APP_PORT:-80}:8080'
|
||||
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
||||
environment:
|
||||
WWWUSER: '${WWWUSER}'
|
||||
@@ -19,6 +19,7 @@ services:
|
||||
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
||||
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
|
||||
IGNITION_LOCAL_SITES_PATH: '${PWD}'
|
||||
TZ: 'Asia/Taipei'
|
||||
volumes:
|
||||
- '.:/var/www/html'
|
||||
networks:
|
||||
@@ -29,8 +30,8 @@ services:
|
||||
|
||||
mysql:
|
||||
image: 'mysql/mysql-server:8.0'
|
||||
container_name: start-cloud-mysql
|
||||
hostname: start-cloud-mysql
|
||||
container_name: star-cloud-mysql
|
||||
hostname: star-cloud-mysql
|
||||
ports:
|
||||
- '${FORWARD_DB_PORT:-3306}:3306'
|
||||
environment:
|
||||
@@ -41,6 +42,7 @@ services:
|
||||
MYSQL_PASSWORD: '${DB_PASSWORD}'
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: 1
|
||||
MYSQL_EXTRA_OPTIONS: '${MYSQL_EXTRA_OPTIONS:-}'
|
||||
TZ: 'Asia/Taipei'
|
||||
volumes:
|
||||
- 'sail-mysql:/var/lib/mysql'
|
||||
- './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
|
||||
@@ -56,8 +58,8 @@ services:
|
||||
timeout: 5s
|
||||
redis:
|
||||
image: 'redis:alpine'
|
||||
container_name: start-cloud-redis
|
||||
hostname: start-cloud-redis
|
||||
container_name: star-cloud-redis
|
||||
hostname: star-cloud-redis
|
||||
ports:
|
||||
- '${FORWARD_REDIS_PORT:-6379}:6379'
|
||||
volumes:
|
||||
|
||||
@@ -2,24 +2,29 @@
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework"
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"laravel/framework": "^10.10",
|
||||
"laravel/sanctum": "^3.3",
|
||||
"laravel/tinker": "^2.8"
|
||||
"php": "^8.2",
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"jenssegers/agent": "^2.6",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"spatie/laravel-permission": "^7.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.9.1",
|
||||
"laravel/breeze": "^1.29",
|
||||
"laravel/pint": "^1.0",
|
||||
"laravel/sail": "^1.18",
|
||||
"mockery/mockery": "^1.4.4",
|
||||
"nunomaduro/collision": "^7.0",
|
||||
"phpunit/phpunit": "^10.1",
|
||||
"spatie/laravel-ignition": "^2.0"
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/breeze": "^2.0",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
2980
composer.lock
generated
2980
composer.lock
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user