From 3ce88ed342c8676f8e7627a9ed2a648804cee729 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Mon, 16 Mar 2026 17:29:15 +0800 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=E9=87=8D=E6=A7=8B=E6=A9=9F=E5=8F=B0?= =?UTF-8?q?=E6=97=A5=E8=AA=8C=20UI=20=E8=88=87=E5=A2=9E=E5=8A=A0=E5=A4=9A?= =?UTF-8?q?=E8=AA=9E=E7=B3=BB=E6=94=AF=E6=8F=B4=EF=BC=8C=E4=B8=A6=E6=95=B4?= =?UTF-8?q?=E5=90=88=20IoT=20API=20=E6=A0=B8=E5=BF=83=E6=9E=B6=E6=A7=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 機台日誌:對齊 Luxury UI 規範,實作整合式佈局與分頁組件。 - 多語系:完成機台日誌繁、英、日三語系翻譯與動態處理。 - UI 規範:更新 SKILL.md 定義「標準列表 Bible」。 - 後端:完善 TenantScoped 隔離邏輯,修復儀表板死循環與 User Model 缺失。 - IoT:擴展機台、會員 Model 並建立交易、商品、狀態等核心表結構。 - 基礎設施:設置台北時區與 Docker 環境變數同步。 --- .agents/rules/rbac-rules.md | 52 +++++ .agents/rules/skill-trigger.md | 3 + .agents/skills/ui-minimal-luxury/SKILL.md | 115 ++++++++-- .env.example | 1 + .../Controllers/Admin/MachineController.php | 2 +- .../Api/V1/App/MachineController.php | 123 +++++++++++ .../Api/V1/App/TransactionController.php | 66 ++++++ app/Http/Kernel.php | 1 + app/Http/Middleware/IotAuth.php | 39 ++++ app/Jobs/Machine/ProcessCoinInventory.php | 57 +++++ app/Jobs/Machine/ProcessHeartbeat.php | 41 ++++ app/Jobs/Machine/ProcessTimerStatus.php | 51 +++++ .../Transaction/ProcessDispenseRecord.php | 39 ++++ app/Jobs/Transaction/ProcessInvoice.php | 42 ++++ app/Jobs/Transaction/ProcessTransaction.php | 39 ++++ app/Models/Machine/CoinInventory.php | 23 ++ app/Models/Machine/Machine.php | 6 + app/Models/Machine/MachineSlot.php | 39 ++++ app/Models/Machine/RemoteCommand.php | 31 +++ app/Models/Machine/TimerStatus.php | 28 +++ app/Models/Member/Member.php | 6 + app/Models/Product/Product.php | 40 ++++ app/Models/Product/ProductCategory.php | 24 ++ app/Models/System/Translation.php | 18 ++ app/Models/System/User.php | 4 +- app/Models/Transaction/DispenseRecord.php | 50 +++++ app/Models/Transaction/Invoice.php | 39 ++++ app/Models/Transaction/Order.php | 64 ++++++ app/Models/Transaction/OrderItem.php | 39 ++++ app/Models/Transaction/PaymentType.php | 23 ++ app/Services/Machine/MachineService.php | 77 ++++--- .../Transaction/TransactionService.php | 120 ++++++++++ app/Traits/TenantScoped.php | 10 + compose.yaml | 2 + config/database.php | 1 + ...3_111437_add_company_id_to_users_table.php | 3 +- ...6_140117_add_company_id_to_roles_table.php | 36 +++ ...6_140129_extend_machines_table_for_iot.php | 33 +++ ...16_140130_extend_members_table_for_iot.php | 39 ++++ ..._create_products_and_categories_tables.php | 51 +++++ ...3_16_140151_create_machine_slots_table.php | 35 +++ ...03_16_140153_create_translations_table.php | 33 +++ ..._create_transactions_and_orders_tables.php | 115 ++++++++++ ...40155_create_iot_log_and_status_tables.php | 63 ++++++ ...rrent_page_to_string_in_machines_table.php | 28 +++ docs/api/iot-spec.md | 87 ++++++++ lang/en.json | 15 +- lang/ja.json | 15 +- lang/zh_TW.json | 12 +- .../admin/data-config/accounts.blade.php | 76 +++---- resources/views/admin/machines/logs.blade.php | 208 +++++++++--------- .../views/admin/permission/roles.blade.php | 62 +++--- .../views/components/breadcrumbs.blade.php | 2 +- routes/api.php | 14 ++ 54 files changed, 2015 insertions(+), 227 deletions(-) create mode 100644 .agents/rules/rbac-rules.md create mode 100644 app/Http/Controllers/Api/V1/App/MachineController.php create mode 100644 app/Http/Controllers/Api/V1/App/TransactionController.php create mode 100644 app/Http/Middleware/IotAuth.php create mode 100644 app/Jobs/Machine/ProcessCoinInventory.php create mode 100644 app/Jobs/Machine/ProcessHeartbeat.php create mode 100644 app/Jobs/Machine/ProcessTimerStatus.php create mode 100644 app/Jobs/Transaction/ProcessDispenseRecord.php create mode 100644 app/Jobs/Transaction/ProcessInvoice.php create mode 100644 app/Jobs/Transaction/ProcessTransaction.php create mode 100644 app/Models/Machine/CoinInventory.php create mode 100644 app/Models/Machine/MachineSlot.php create mode 100644 app/Models/Machine/RemoteCommand.php create mode 100644 app/Models/Machine/TimerStatus.php create mode 100644 app/Models/Product/Product.php create mode 100644 app/Models/Product/ProductCategory.php create mode 100644 app/Models/System/Translation.php create mode 100644 app/Models/Transaction/DispenseRecord.php create mode 100644 app/Models/Transaction/Invoice.php create mode 100644 app/Models/Transaction/Order.php create mode 100644 app/Models/Transaction/OrderItem.php create mode 100644 app/Models/Transaction/PaymentType.php create mode 100644 app/Services/Transaction/TransactionService.php create mode 100644 database/migrations/2026_03_16_140117_add_company_id_to_roles_table.php create mode 100644 database/migrations/2026_03_16_140129_extend_machines_table_for_iot.php create mode 100644 database/migrations/2026_03_16_140130_extend_members_table_for_iot.php create mode 100644 database/migrations/2026_03_16_140141_create_products_and_categories_tables.php create mode 100644 database/migrations/2026_03_16_140151_create_machine_slots_table.php create mode 100644 database/migrations/2026_03_16_140153_create_translations_table.php create mode 100644 database/migrations/2026_03_16_140154_create_transactions_and_orders_tables.php create mode 100644 database/migrations/2026_03_16_140155_create_iot_log_and_status_tables.php create mode 100644 database/migrations/2026_03_16_163354_change_current_page_to_string_in_machines_table.php create mode 100644 docs/api/iot-spec.md diff --git a/.agents/rules/rbac-rules.md b/.agents/rules/rbac-rules.md new file mode 100644 index 0000000..bf845e0 --- /dev/null +++ b/.agents/rules/rbac-rules.md @@ -0,0 +1,52 @@ +# 多租戶與權限架構實作規範 (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 過濾。 diff --git a/.agents/rules/skill-trigger.md b/.agents/rules/skill-trigger.md index 20ed2d9..acc9130 100644 --- a/.agents/rules/skill-trigger.md +++ b/.agents/rules/skill-trigger.md @@ -17,6 +17,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。 | 機台通訊, 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` | --- @@ -27,6 +28,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。 ### 🔴 新增或修改頁面 (Views/Blade) 時 必須讀取: 1. **ui-minimal-luxury** — 確保符合極簡奢華風視覺與互動規範 +2. **rbac-rules** — 確認 UI 區塊的權限顯示控制 ### 🔴 新增機台通訊 API 端點時 必須讀取: @@ -39,3 +41,4 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。 ### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時 必須讀取: 1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範 +2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用 diff --git a/.agents/skills/ui-minimal-luxury/SKILL.md b/.agents/skills/ui-minimal-luxury/SKILL.md index 27002bc..f588fc9 100644 --- a/.agents/skills/ui-minimal-luxury/SKILL.md +++ b/.agents/skills/ui-minimal-luxury/SKILL.md @@ -64,14 +64,9 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範 - **互動原則**: 點擊觸發下拉選單時,必須使用 `x-transition` 且帶有 `scale` 偏移。 - **樣式要求**: 選單背景需使用玻璃擬態 (Glassmorphism) 或帶透明度的深色背景。 -## 4. UI 檢查清單 (AI 助手執行前必讀) -- [ ] 是否使用了正確的 `rounded-2xl` (或更圓) 的導角? -- [ ] 所有的圖示是否一致使用 `lucide-react` 風格? -- [ ] 卡片是否有適當的間距 (通常為 `p-6`)? -- [ ] 文字色階是否符合: - - **標題**: `text-slate-900` / `dark:text-white` - - **副標/標籤**: 最小應為 `text-slate-500` / `dark:text-slate-400`(避免使用 `slate-400` 以下等級,以確保對比度足以閱讀)。 -- [ ] **字體大小**: 確保所有文字至少為 `text-xs`,重要的標籤建議為 `text-sm`。 +- [ ] **列表佈局**: 是否採用「整合式卡片」結構且內距設為 `p-8`? +- [ ] **分頁與總數**: 列表底部是否正確召喚 `vendor.pagination.luxury`? +- [ ] **文字色階**: 符合標題 `slate-900/white` 與標籤 `slate-500` 的對比度。 ## 5. 開發注意事項 (Important Notes) @@ -85,6 +80,23 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範 ## 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 @@ -146,23 +158,21 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範 ### 文字大小與權重 (Typography Hierarchy) - **表頭 (Table Header)**: - - 類別: `text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest` + - 類別: `text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em]` - 作用: 提供清晰的欄位定義而不奪取資料視覺焦點。 - **主標題 (Primary Item)**: - 類別: `text-base font-extrabold text-slate-800 dark:text-slate-100` - - 範例: 公司名稱、機台標題。 + - 範例: 公司名稱、機體名稱。 - **次要資訊 (Secondary Info)**: - - 類別: `text-[11px] font-bold text-slate-400 dark:text-slate-500 tracking-[0.15em]` - - 範例: 機台序號 (SN)、公司代碼。 + - 類別: `text-[11px] font-bold text-slate-400 dark:text-slate-500 tracking-[0.1em]` + - 範例: 使用者帳號、備註、權限名稱。 - **狀態標籤 (Status Badge)**: - - 類別: `text-[11px] font-black tracking-widest` - - 樣式: 在線 (`emerald`)、離線 (`rose`)。 -- **時間訊號 (Signals/Time)**: - - 類別: `text-[13px] font-bold font-display tracking-widest` - - 作用: 解決數字黏滯感,提升判讀舒適度。 + - 範例: 啟用 (`emerald`)、禁用 (`rose`) / 角色名稱 (`sky`/`indigo`)。 + - 特性: `px-2.5 py-1 rounded-lg text-[11px] font-black border tracking-wider` -- **內距 (Padding)**: 單元格統一使用 `px-6 py-6` 以維持呼吸感。 -- **懸停 (Hover)**: 表格行需具備 `hover:bg-slate-50/80` (深色: `dark:hover:bg-slate-800/40`) 動態反饋。 +### 空間與反應 (Spacing & Interaction) +- **單元格內距**: 統一使用 `px-6 py-6`。 +- **懸停反應**: 必須在 `tr` 套用 `group` 且子元素套用 `group-hover:bg-slate-50/80` (深色: `dark:group-hover:bg-slate-800/40`) 以提供高級的互動感知。 ### 分頁與列表控制項 (Pagination & Controls) 為了維持操作一致性,所有列表的分頁與切換組件必須遵循以下「Luxury Jump」模式: @@ -177,6 +187,73 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範 - **指示文字**: - 行動端隱藏多餘詞彙,僅保留「1 - 10 / 50」格式。 - 數字顏色對齊 `text-slate-600` (深色: `text-slate-300`)。 + +### 底部清單控制項 (Bottom List Controls) +為了確保長列表的操作便利,清單底部必須具備以下元素: +- **位置**: 卡片底部,內距 `px-8 py-6`,並帶有 `border-t border-slate-100/50 dark:border-slate-800/50`。 +- **左側:每頁筆數 (Per Page Selector)**: + - 樣式: `luxury-select` (緊湊型),高度固定為 `h-9`。 + - 規範: 提供 `20, 50, 100` 等選項,並在變更時立即提交。 +- **中央:資料指示 (Info)**: + - 樣式: `text-[11px] font-bold tracking-widest uppercase text-slate-400`。 + - 內容: `Showing X to Y of Z results`。 +- **右側:分頁導航 (Pagination)**: + - 模式: 優先使用 `Luxury Jump` (跳轉下拉選單) 以節省空間並提升效率。 + +### 標準清單萬用模板 (Standard List View Bible) +建立新列表頁面時,**必須**以此結構為基底: + +```html +
+ +
+
+

{{ __('Title') }}

+

{{ __('Subtitle') }}

+
+
+ +
+
+ + +
+ +
+
+ +
+
+ + +
+ + + + + + + + + + + + + +
{{ __('Name') }}{{ __('Action') }}
Example Name
+
+ + +
+ {{ $items->links('vendor.pagination.luxury') }} +
+
+
+``` + +### 清單欄位規範 (Column Visibility & Standards) +- **固定欄位**: 第一欄通常為「關鍵標識」(如 ID 或時間),應具備特殊字體樣式。 +- **操作欄位**: 統一位於表格最右端,並命名為 `Action` (或 `操作`),標題與內容皆應 `text-right`。 ## 9. 系統兼容性與標準化 (Compatibility & Standardization) 為了確保在不同版本的開發環境中(如目前專案使用的 Tailwind CSS v3.1)UI 都能正確呈現,並維持全站操作感一致,必須遵守以下額外規範。 diff --git a/.env.example b/.env.example index cb21a1f..59fa6b2 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ APP_PORT=8090 APP_LOCALE=zh_TW APP_TIMEZONE=Asia/Taipei +DB_TIMEZONE="+08:00" APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US diff --git a/app/Http/Controllers/Admin/MachineController.php b/app/Http/Controllers/Admin/MachineController.php index f7eb53f..e1c89d5 100644 --- a/app/Http/Controllers/Admin/MachineController.php +++ b/app/Http/Controllers/Admin/MachineController.php @@ -42,7 +42,7 @@ class MachineController extends AdminController */ public function logs(Request $request): View { - $per_page = $request->input('per_page', 20); + $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); diff --git a/app/Http/Controllers/Api/V1/App/MachineController.php b/app/Http/Controllers/Api/V1/App/MachineController.php new file mode 100644 index 0000000..cdb1810 --- /dev/null +++ b/app/Http/Controllers/Api/V1/App/MachineController.php @@ -0,0 +1,123 @@ +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, + ] + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/App/TransactionController.php b/app/Http/Controllers/Api/V1/App/TransactionController.php new file mode 100644 index 0000000..09e482b --- /dev/null +++ b/app/Http/Controllers/Api/V1/App/TransactionController.php @@ -0,0 +1,66 @@ +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); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 4127281..183e883 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -69,5 +69,6 @@ class Kernel extends HttpKernel '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, ]; } diff --git a/app/Http/Middleware/IotAuth.php b/app/Http/Middleware/IotAuth.php new file mode 100644 index 0000000..71e25a9 --- /dev/null +++ b/app/Http/Middleware/IotAuth.php @@ -0,0 +1,39 @@ +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); + } +} diff --git a/app/Jobs/Machine/ProcessCoinInventory.php b/app/Jobs/Machine/ProcessCoinInventory.php new file mode 100644 index 0000000..0fbc972 --- /dev/null +++ b/app/Jobs/Machine/ProcessCoinInventory.php @@ -0,0 +1,57 @@ +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; + } + } +} diff --git a/app/Jobs/Machine/ProcessHeartbeat.php b/app/Jobs/Machine/ProcessHeartbeat.php new file mode 100644 index 0000000..92a68e8 --- /dev/null +++ b/app/Jobs/Machine/ProcessHeartbeat.php @@ -0,0 +1,41 @@ +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; + } + } +} diff --git a/app/Jobs/Machine/ProcessTimerStatus.php b/app/Jobs/Machine/ProcessTimerStatus.php new file mode 100644 index 0000000..77f0753 --- /dev/null +++ b/app/Jobs/Machine/ProcessTimerStatus.php @@ -0,0 +1,51 @@ +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; + } + } +} diff --git a/app/Jobs/Transaction/ProcessDispenseRecord.php b/app/Jobs/Transaction/ProcessDispenseRecord.php new file mode 100644 index 0000000..08b4785 --- /dev/null +++ b/app/Jobs/Transaction/ProcessDispenseRecord.php @@ -0,0 +1,39 @@ +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; + } + } +} diff --git a/app/Jobs/Transaction/ProcessInvoice.php b/app/Jobs/Transaction/ProcessInvoice.php new file mode 100644 index 0000000..67b211e --- /dev/null +++ b/app/Jobs/Transaction/ProcessInvoice.php @@ -0,0 +1,42 @@ +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; + } + } +} diff --git a/app/Jobs/Transaction/ProcessTransaction.php b/app/Jobs/Transaction/ProcessTransaction.php new file mode 100644 index 0000000..235e528 --- /dev/null +++ b/app/Jobs/Transaction/ProcessTransaction.php @@ -0,0 +1,39 @@ +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; + } + } +} diff --git a/app/Models/Machine/CoinInventory.php b/app/Models/Machine/CoinInventory.php new file mode 100644 index 0000000..40e91df --- /dev/null +++ b/app/Models/Machine/CoinInventory.php @@ -0,0 +1,23 @@ +belongsTo(Machine::class); + } +} diff --git a/app/Models/Machine/Machine.php b/app/Models/Machine/Machine.php index d4c4083..6a29848 100644 --- a/app/Models/Machine/Machine.php +++ b/app/Models/Machine/Machine.php @@ -10,14 +10,20 @@ 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', ]; diff --git a/app/Models/Machine/MachineSlot.php b/app/Models/Machine/MachineSlot.php new file mode 100644 index 0000000..aaf419a --- /dev/null +++ b/app/Models/Machine/MachineSlot.php @@ -0,0 +1,39 @@ + 'decimal:2', + 'last_restocked_at' => 'datetime', + ]; + + public function machine() + { + return $this->belongsTo(Machine::class); + } + + public function product() + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/Machine/RemoteCommand.php b/app/Models/Machine/RemoteCommand.php new file mode 100644 index 0000000..f9de2de --- /dev/null +++ b/app/Models/Machine/RemoteCommand.php @@ -0,0 +1,31 @@ + 'array', + 'response_payload' => 'array', + 'executed_at' => 'datetime', + ]; + + public function machine() + { + return $this->belongsTo(Machine::class); + } +} diff --git a/app/Models/Machine/TimerStatus.php b/app/Models/Machine/TimerStatus.php new file mode 100644 index 0000000..ff43100 --- /dev/null +++ b/app/Models/Machine/TimerStatus.php @@ -0,0 +1,28 @@ + 'datetime', + ]; + + public function machine() + { + return $this->belongsTo(Machine::class); + } +} diff --git a/app/Models/Member/Member.php b/app/Models/Member/Member.php index 86ca3eb..d2a1b2a 100644 --- a/app/Models/Member/Member.php +++ b/app/Models/Member/Member.php @@ -31,6 +31,10 @@ class Member extends Authenticatable 'avatar', 'is_active', 'email_verified_at', + 'company_id', + 'barcode', + 'points', + 'wallet_balance', ]; /** @@ -49,6 +53,8 @@ class Member extends Authenticatable 'birthday' => 'date', 'is_active' => 'boolean', 'password' => 'hashed', + 'points' => 'integer', + 'wallet_balance' => 'decimal:2', ]; /** diff --git a/app/Models/Product/Product.php b/app/Models/Product/Product.php new file mode 100644 index 0000000..2b33bd8 --- /dev/null +++ b/app/Models/Product/Product.php @@ -0,0 +1,40 @@ + 'decimal:2', + 'cost' => 'decimal:2', + 'metadata' => 'array', + ]; + + public function category() + { + return $this->belongsTo(ProductCategory::class, 'category_id'); + } +} diff --git a/app/Models/Product/ProductCategory.php b/app/Models/Product/ProductCategory.php new file mode 100644 index 0000000..0141415 --- /dev/null +++ b/app/Models/Product/ProductCategory.php @@ -0,0 +1,24 @@ +hasMany(Product::class, 'category_id'); + } +} diff --git a/app/Models/System/Translation.php b/app/Models/System/Translation.php new file mode 100644 index 0000000..64bba4e --- /dev/null +++ b/app/Models/System/Translation.php @@ -0,0 +1,18 @@ + '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); + } +} diff --git a/app/Models/Transaction/Invoice.php b/app/Models/Transaction/Invoice.php new file mode 100644 index 0000000..f95a1b7 --- /dev/null +++ b/app/Models/Transaction/Invoice.php @@ -0,0 +1,39 @@ + 'decimal:2', + 'tax_amount' => 'decimal:2', + 'metadata' => 'array', + ]; + + public function order() + { + return $this->belongsTo(Order::class); + } +} diff --git a/app/Models/Transaction/Order.php b/app/Models/Transaction/Order.php new file mode 100644 index 0000000..6e78ed1 --- /dev/null +++ b/app/Models/Transaction/Order.php @@ -0,0 +1,64 @@ + '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); + } +} diff --git a/app/Models/Transaction/OrderItem.php b/app/Models/Transaction/OrderItem.php new file mode 100644 index 0000000..f7858c3 --- /dev/null +++ b/app/Models/Transaction/OrderItem.php @@ -0,0 +1,39 @@ + 'decimal:2', + 'subtotal' => 'decimal:2', + 'metadata' => 'array', + ]; + + public function order() + { + return $this->belongsTo(Order::class); + } + + public function product() + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/Transaction/PaymentType.php b/app/Models/Transaction/PaymentType.php new file mode 100644 index 0000000..71b5615 --- /dev/null +++ b/app/Models/Transaction/PaymentType.php @@ -0,0 +1,23 @@ + 'array', + ]; +} diff --git a/app/Services/Machine/MachineService.php b/app/Services/Machine/MachineService.php index 494ea05..ad0b93e 100644 --- a/app/Services/Machine/MachineService.php +++ b/app/Services/Machine/MachineService.php @@ -4,42 +4,69 @@ namespace App\Services\Machine; use App\Models\Machine\Machine; use App\Models\Machine\MachineLog; -use Illuminate\Support\Facades\Log; +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); - // 建立日誌紀錄 - $log = $machine->logs()->create([ + return $machine->logs()->create([ 'level' => $data['level'] ?? 'info', 'message' => $data['message'], - 'context' => $data['context'] ?? null, + 'payload' => $data['context'] ?? null, ]); - - // 同步更新機台最後活耀時間與狀態 - $machine->update([ - 'last_heartbeat_at' => now(), - 'status' => $this->resolveStatus($data), - ]); - - return $log; - } - - /** - * 根據日誌內容判斷機台是否應標記成錯誤 - */ - protected function resolveStatus(array $data): string - { - if (isset($data['level']) && $data['level'] === 'error') { - return 'error'; - } - - return 'online'; } } diff --git a/app/Services/Transaction/TransactionService.php b/app/Services/Transaction/TransactionService.php new file mode 100644 index 0000000..5816043 --- /dev/null +++ b/app/Services/Transaction/TransactionService.php @@ -0,0 +1,120 @@ +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(), + ]); + }); + } +} diff --git a/app/Traits/TenantScoped.php b/app/Traits/TenantScoped.php index 3b2c307..8f77bcf 100644 --- a/app/Traits/TenantScoped.php +++ b/app/Traits/TenantScoped.php @@ -12,6 +12,16 @@ trait TenantScoped 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(); // 如果使用者已登入且有綁定公司,則自動注入過濾條件 diff --git a/compose.yaml b/compose.yaml index 3acad18..52ff10e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: @@ -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' diff --git a/config/database.php b/config/database.php index 137ad18..84b127a 100644 --- a/config/database.php +++ b/config/database.php @@ -57,6 +57,7 @@ return [ 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, + 'timezone' => env('DB_TIMEZONE', '+08:00'), 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), diff --git a/database/migrations/2026_03_13_111437_add_company_id_to_users_table.php b/database/migrations/2026_03_13_111437_add_company_id_to_users_table.php index c9199ef..9e7a8b8 100644 --- a/database/migrations/2026_03_13_111437_add_company_id_to_users_table.php +++ b/database/migrations/2026_03_13_111437_add_company_id_to_users_table.php @@ -15,6 +15,7 @@ return new class extends Migration $table->foreignId('company_id')->nullable()->after('id') ->constrained('companies')->nullOnDelete(); $table->tinyInteger('status')->default(1)->after('role'); // 1:啟用, 0:停用 + $table->softDeletes(); }); } @@ -25,7 +26,7 @@ return new class extends Migration { Schema::table('users', function (Blueprint $table) { $table->dropConstrainedForeignId('company_id'); - $table->dropColumn('status'); + $table->dropColumn(['status', 'deleted_at']); }); } }; diff --git a/database/migrations/2026_03_16_140117_add_company_id_to_roles_table.php b/database/migrations/2026_03_16_140117_add_company_id_to_roles_table.php new file mode 100644 index 0000000..1dfbcf5 --- /dev/null +++ b/database/migrations/2026_03_16_140117_add_company_id_to_roles_table.php @@ -0,0 +1,36 @@ +foreignId('company_id')->nullable()->after('id') + ->constrained('companies')->onDelete('cascade'); + + // 移除舊有的唯一鍵 + $table->dropUnique('roles_name_guard_name_unique'); + // 新增複合唯一鍵 (涵蓋租戶) + $table->unique(['name', 'guard_name', 'company_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('roles', function (Blueprint $table) { + $table->dropUnique(['name', 'guard_name', 'company_id']); + $table->unique(['name', 'guard_name']); + $table->dropConstrainedForeignId('company_id'); + }); + } +}; diff --git a/database/migrations/2026_03_16_140129_extend_machines_table_for_iot.php b/database/migrations/2026_03_16_140129_extend_machines_table_for_iot.php new file mode 100644 index 0000000..c087a92 --- /dev/null +++ b/database/migrations/2026_03_16_140129_extend_machines_table_for_iot.php @@ -0,0 +1,33 @@ +string('serial_no')->unique()->after('name')->comment('機台序號'); + $table->string('model')->nullable()->after('serial_no')->comment('型號'); + $table->tinyInteger('current_page')->default(0)->after('status')->comment('當前頁面狀態'); + $table->string('door_status')->nullable()->after('current_page')->comment('門禁狀態'); + $table->string('api_token')->nullable()->after('firmware_version')->comment('API Token'); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('machines', function (Blueprint $table) { + $table->dropColumn(['serial_no', 'model', 'current_page', 'door_status', 'api_token', 'deleted_at']); + }); + } +}; diff --git a/database/migrations/2026_03_16_140130_extend_members_table_for_iot.php b/database/migrations/2026_03_16_140130_extend_members_table_for_iot.php new file mode 100644 index 0000000..eb4338a --- /dev/null +++ b/database/migrations/2026_03_16_140130_extend_members_table_for_iot.php @@ -0,0 +1,39 @@ +foreignId('company_id')->nullable()->constrained()->onDelete('cascade'); + } + if (!Schema::hasColumn('members', 'barcode')) { + $table->string('barcode')->nullable()->index(); + } + if (!Schema::hasColumn('members', 'points')) { + $table->integer('points')->default(0); + } + if (!Schema::hasColumn('members', 'wallet_balance')) { + $table->decimal('wallet_balance', 10, 2)->default(0); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('members', function (Blueprint $table) { + $table->dropColumn(['company_id', 'barcode', 'points', 'wallet_balance']); + }); + } +}; diff --git a/database/migrations/2026_03_16_140141_create_products_and_categories_tables.php b/database/migrations/2026_03_16_140141_create_products_and_categories_tables.php new file mode 100644 index 0000000..5578cea --- /dev/null +++ b/database/migrations/2026_03_16_140141_create_products_and_categories_tables.php @@ -0,0 +1,51 @@ +id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->onDelete('cascade'); + $table->string('name'); + $table->string('name_dictionary_key')->nullable()->comment('多語系 Key'); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('products', function (Blueprint $table) { + $table->id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->onDelete('cascade'); + $table->foreignId('category_id')->nullable()->constrained('product_categories')->onDelete('set null'); + $table->string('name'); + $table->string('name_dictionary_key')->nullable()->comment('多語系 Key'); + $table->string('sku')->nullable()->comment('商品編號'); + $table->decimal('price', 10, 2)->default(0)->comment('售價'); + $table->decimal('cost', 10, 2)->nullable()->comment('成本'); + $table->string('image')->nullable()->comment('圖片 URL'); + $table->string('barcode')->nullable()->comment('條碼'); + $table->boolean('is_timer_product')->default(false)->comment('是否為計時型商品'); + $table->boolean('is_active')->default(true)->comment('是否上架'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['company_id', 'sku']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('products'); + Schema::dropIfExists('product_categories'); + } +}; diff --git a/database/migrations/2026_03_16_140151_create_machine_slots_table.php b/database/migrations/2026_03_16_140151_create_machine_slots_table.php new file mode 100644 index 0000000..c42d0d5 --- /dev/null +++ b/database/migrations/2026_03_16_140151_create_machine_slots_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('machine_id')->constrained('machines')->onDelete('cascade'); + $table->string('slot_no')->comment('貨道編號 (B017 tid, B710 cid)'); + $table->foreignId('product_id')->nullable()->constrained('products')->onDelete('set null'); + $table->integer('stock')->default(0)->comment('當前庫存'); + $table->integer('max_stock')->default(0)->comment('最大容量'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['machine_id', 'slot_no']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('machine_slots'); + } +}; diff --git a/database/migrations/2026_03_16_140153_create_translations_table.php b/database/migrations/2026_03_16_140153_create_translations_table.php new file mode 100644 index 0000000..6d03f74 --- /dev/null +++ b/database/migrations/2026_03_16_140153_create_translations_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('group', 50)->comment('分組 (product, category, system)'); + $table->string('key', 100)->comment('字典鍵值'); + $table->string('locale', 10)->comment('語系代碼'); + $table->text('value')->comment('翻譯內容'); + $table->timestamps(); + + $table->unique(['group', 'key', 'locale']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('translations'); + } +}; diff --git a/database/migrations/2026_03_16_140154_create_transactions_and_orders_tables.php b/database/migrations/2026_03_16_140154_create_transactions_and_orders_tables.php new file mode 100644 index 0000000..fbe32ad --- /dev/null +++ b/database/migrations/2026_03_16_140154_create_transactions_and_orders_tables.php @@ -0,0 +1,115 @@ +id(); + $table->smallInteger('code')->unique()->comment('金流代碼'); + $table->string('name')->comment('中文名稱'); + $table->string('category')->nullable()->comment('大分類'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->onDelete('cascade'); + $table->string('flow_id')->nullable()->unique()->comment('Cloud 金流 ID (B600 response)'); + $table->string('order_no')->nullable()->index()->comment('APP 訂單號 (B600 req9)'); + $table->foreignId('machine_id')->constrained('machines')->onDelete('cascade'); + $table->foreignId('member_id')->nullable()->constrained('members')->onDelete('set null'); + $table->smallInteger('payment_type')->comment('金流類型碼'); + $table->decimal('total_amount', 10, 2)->comment('消費金額'); + $table->decimal('discount_amount', 10, 2)->default(0)->comment('折扣金額'); + $table->decimal('pay_amount', 10, 2)->comment('實付金額'); + $table->decimal('change_amount', 10, 2)->default(0)->comment('找零'); + $table->integer('points_used')->default(0)->comment('使用點數'); + $table->decimal('original_amount', 10, 2)->nullable()->comment('折扣前金額'); + $table->tinyInteger('payment_status')->comment('0:失敗/1:成功'); + $table->text('payment_request')->nullable()->comment('金流送出 data'); + $table->text('payment_response')->nullable()->comment('金流回傳 data'); + $table->datetime('payment_at')->nullable()->comment('支付時間'); + $table->string('member_barcode')->nullable()->comment('會員條碼'); + $table->string('invoice_info')->nullable()->comment('發票歸戶'); + $table->datetime('machine_time')->nullable()->comment('機台時間'); + $table->string('status')->default('completed')->comment('訂單狀態'); + $table->json('metadata')->nullable()->comment('其他資訊'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['company_id', 'machine_id', 'created_at']); + }); + + Schema::create('order_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('order_id')->constrained('orders')->onDelete('cascade'); + $table->foreignId('product_id')->constrained('products')->onDelete('cascade'); + $table->string('product_name')->nullable()->comment('商品名稱 (備份)'); + $table->string('sku')->nullable()->comment('商品編號 (備份)'); + $table->integer('quantity'); + $table->decimal('price', 10, 2); + $table->decimal('subtotal', 10, 2); + $table->timestamps(); + }); + + Schema::create('invoices', function (Blueprint $table) { + $table->id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->onDelete('cascade'); + $table->foreignId('order_id')->nullable()->constrained('orders')->onDelete('set null'); + $table->string('flow_id')->index()->comment('金流 ID'); + $table->foreignId('machine_id')->constrained('machines')->onDelete('cascade'); + $table->string('rtn_code')->nullable(); + $table->string('rtn_msg')->nullable(); + $table->string('invoice_no')->nullable(); + $table->decimal('amount', 10, 2)->default(0); + $table->string('carrier_id')->nullable(); + $table->date('invoice_date')->nullable(); + $table->string('random_number')->nullable(); + $table->string('love_code')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('dispense_records', function (Blueprint $table) { + $table->id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->onDelete('cascade'); + $table->foreignId('order_id')->nullable()->constrained('orders')->onDelete('set null'); + $table->string('flow_id')->nullable()->index()->comment('金流 ID'); + $table->foreignId('machine_id')->constrained('machines')->onDelete('cascade'); + $table->string('product_id')->comment('機台端傳入之商品 ID'); + $table->string('slot_no')->comment('貨道編號'); + $table->decimal('amount', 10, 2); + $table->integer('remaining_stock')->nullable(); + $table->tinyInteger('dispense_status')->comment('0:成功/1:失敗'); + $table->string('member_barcode')->nullable(); + $table->datetime('machine_time')->nullable(); + $table->integer('points_used')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['company_id', 'machine_id', 'flow_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('dispense_records'); + Schema::dropIfExists('invoices'); + Schema::dropIfExists('order_items'); + Schema::dropIfExists('orders'); + Schema::dropIfExists('payment_types'); + } +}; diff --git a/database/migrations/2026_03_16_140155_create_iot_log_and_status_tables.php b/database/migrations/2026_03_16_140155_create_iot_log_and_status_tables.php new file mode 100644 index 0000000..2ea8b85 --- /dev/null +++ b/database/migrations/2026_03_16_140155_create_iot_log_and_status_tables.php @@ -0,0 +1,63 @@ +id(); + $table->foreignId('machine_id')->constrained('machines')->onDelete('cascade'); + $table->string('command_type', 50)->comment('reboot, lock, stock_update, dispense...'); + $table->enum('status', ['pending', 'sent', 'success', 'failed'])->default('pending'); + $table->json('payload')->nullable()->comment('指令參數'); + $table->integer('ttl')->default(60)->comment('失效秒數'); + $table->timestamp('executed_at')->nullable(); + $table->timestamps(); + + $table->index(['machine_id', 'status']); + }); + + Schema::create('coin_inventories', function (Blueprint $table) { + $table->id(); + $table->foreignId('machine_id')->constrained('machines')->onDelete('cascade'); + $table->integer('value_1')->default(0); + $table->integer('value_5')->default(0); + $table->integer('value_10')->default(0); + $table->integer('value_50')->default(0); + $table->integer('value_100')->default(0); + $table->integer('value_500')->default(0); + $table->integer('value_1000')->default(0); + $table->string('operator')->nullable()->comment('操作人 (0=消費者)'); + $table->timestamps(); + }); + + Schema::create('timer_statuses', function (Blueprint $table) { + $table->id(); + $table->foreignId('machine_id')->constrained('machines')->onDelete('cascade'); + $table->string('slot_no')->comment('貨道 ID (B710 cid)'); + $table->foreignId('product_id')->nullable()->constrained('products')->onDelete('set null'); + $table->tinyInteger('status')->default(0)->comment('0:未啟用/1:使用中/2:異常'); + $table->integer('remaining_seconds')->default(0); + $table->timestamps(); + + $table->unique(['machine_id', 'slot_no']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('timer_statuses'); + Schema::dropIfExists('coin_inventories'); + Schema::dropIfExists('remote_commands'); + } +}; diff --git a/database/migrations/2026_03_16_163354_change_current_page_to_string_in_machines_table.php b/database/migrations/2026_03_16_163354_change_current_page_to_string_in_machines_table.php new file mode 100644 index 0000000..484a6da --- /dev/null +++ b/database/migrations/2026_03_16_163354_change_current_page_to_string_in_machines_table.php @@ -0,0 +1,28 @@ +string('current_page')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('machines', function (Blueprint $table) { + $table->tinyInteger('current_page')->default(0)->change(); + }); + } +}; diff --git a/docs/api/iot-spec.md b/docs/api/iot-spec.md new file mode 100644 index 0000000..544051f --- /dev/null +++ b/docs/api/iot-spec.md @@ -0,0 +1,87 @@ +# IoT API 測試與對接文件 (IoT API Testing & Documentation) + +本文件紀錄 Star Cloud IoT API 的測試紀錄與對接規格,供後續開發與測試追蹤。 + +--- + +## 🟢 B010: 心跳上報與狀態同步 (Heartbeat & Status) +機台定時(建議每 5-10 秒)上送,用於確認連線狀態、溫度及門禁狀態。 + +### 1. API 資訊 +- **Endpoint**: `POST /api/v1/app/machine/status/B010` +- **認證方式**: Bearer Token (或 Request Body 帶 `key`) +- **處理方式**: 異步處理 (Redis Queue),立即回傳 202。 + +### 2. 請求範例 (JSON) +```json +{ + "temperature": 5.2, + "door_status": 0, + "current_page": "home", + "firmware_version": "1.0.5", + "log": "Status heartbeat test", + "log_level": "info" +} +``` + +### 3. 回應規格 +- **成功**: `202 Accepted` +```json +{ + "success": true, + "code": 200, + "message": "OK", + "status": "49" +} +``` + +--- + +## 🔵 B600: 交易紀錄上報 (Transaction Record) +當機台端完成交易(收款或扣點成功)後上傳。 + +### 1. API 資訊 +- **Endpoint**: `POST /api/v1/app/B600` +- **認證方式**: Bearer Token +- **處理方式**: 異步處理 (Redis Queue)。 + +### 2. 請求範例 (JSON) +```json +{ + "flow_id": "F123456789", + "total_amount": 100, + "pay_amount": 100, + "payment_type": 1, + "items": [ + { + "product_id": 1, + "product_name": "Test Product", + "price": 50, + "quantity": 2 + } + ] +} +``` + +--- + +## 📝 測試紀錄 (Test Logs) + +### 2026-03-16: B600 交易功能測試 +- **測試機台**: `SN001` +- **測試方式**: `curl` 命令模擬 +- **驗證項目**: + - [ ] HTTP 回傳 `202` + - [ ] 資料庫 `orders` 產生紀錄 + - [ ] 資料庫 `order_items` 產生紀錄 +- **結果**: 準備中... + +### 2026-03-16: B010 首次演練測試 +- **測試機台**: `SN001` +- **測試方式**: `curl` 命令模擬 +- **驗證項目**: + - [x] HTTP 回傳 `202` + - [x] 資料庫 `machines.last_heartbeat_at` 更新為台北時間 + - [x] `machine_logs` 產生對應日誌 +- **測試備註**: 過程中發現 `current_page` 為 `tinyInteger` 導致寫入失敗,已將其修改為 `string` 以支援彈性的頁面名稱。 +- **結果**: ✅ **成功 (SUCCESS)**。機台狀態已同步為 5.2度,當前頁面「home」。 diff --git a/lang/en.json b/lang/en.json index df4e203..e756e9c 100644 --- a/lang/en.json +++ b/lang/en.json @@ -25,7 +25,6 @@ "Permanently Delete Account": "Permanently Delete Account", "Password": "Password", "Enter your password to confirm": "Enter your password to confirm", - "Dashboard": "Dashboard", "Connectivity Status": "Connectivity Status", "Real-time status monitoring": "Real-time status monitoring", @@ -259,5 +258,15 @@ "to": "to", "of": "of", "items": "items", - "Showing": "Showing" -} + "Showing": "Showing", + "Monitor events and system activity across your vending fleet.": "Monitor events and system activity across your vending fleet.", + "All Machines": "All Machines", + "All Levels": "All Levels", + "Timestamp": "Timestamp", + "Message Content": "Message Content", + "No matching logs found": "No matching logs found", + "Unknown": "Unknown", + "Info": "Info", + "Warning": "Warning", + "Error": "Error" +} \ No newline at end of file diff --git a/lang/ja.json b/lang/ja.json index 7327ac1..1687864 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -25,7 +25,6 @@ "Permanently Delete Account": "アカウントを永久に削除", "Password": "パスワード", "Enter your password to confirm": "確認のためパスワードを入力してください", - "Dashboard": "ダッシュボード", "Connectivity Status": "接続ステータス概況", "Real-time status monitoring": "リアルタイムステータス監視", @@ -260,5 +259,15 @@ "to": "から", "of": "件中", "items": "個の項目", - "Showing": "表示中" -} + "Showing": "表示中", + "Monitor events and system activity across your vending fleet.": "自販機フリート全体のイベントとシステムアクティビティを監視します。", + "All Machines": "すべての機体", + "All Levels": "すべてのレベル", + "Timestamp": "タイムスタンプ", + "Message Content": "ログ内容", + "No matching logs found": "一致するログが見つかりません", + "Unknown": "不明", + "Info": "情報", + "Warning": "警告", + "Error": "エラー" +} \ No newline at end of file diff --git a/lang/zh_TW.json b/lang/zh_TW.json index e88d22f..261bbe6 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -263,5 +263,15 @@ "super-admin": "超級管理員", "admin": "管理員", "user": "一般用戶", - "Product Status": "商品狀態" + "Product Status": "商品狀態", + "Monitor events and system activity across your vending fleet.": "跨機台連線動態與系統日誌監控。", + "All Machines": "所有機台", + "All Levels": "所有層級", + "Timestamp": "時間戳記", + "Message Content": "日誌內容", + "No matching logs found": "找不到符合條件的日誌", + "Unknown": "未知", + "Info": "一般", + "Warning": "警告", + "Error": "錯誤" } \ No newline at end of file diff --git a/resources/views/admin/data-config/accounts.blade.php b/resources/views/admin/data-config/accounts.blade.php index 05c5677..d4175d5 100644 --- a/resources/views/admin/data-config/accounts.blade.php +++ b/resources/views/admin/data-config/accounts.blade.php @@ -29,29 +29,32 @@ } }"> -
+

{{ __('Account Management') }}

-

{{ __('Manage administrative and tenant accounts') }}

+

{{ __('Manage administrative and tenant accounts') }}

+
+
+
-
- +
-
-
-
+ + +
+
- +
@@ -63,42 +66,37 @@ @endforeach @endif - -
- -
-
-
+
- - -@if(auth()->user()->isSystemAdmin()) - -@endif - - - + + + @if(auth()->user()->isSystemAdmin()) + + @endif + + + - + @forelse($users as $user) @@ -107,37 +105,38 @@ @if($user->company) {{ $user->company->name }} @else - {{ __('SYSTEM') }} + {{ __('SYSTEM') }} @endif @endif @endforelse diff --git a/resources/views/admin/machines/logs.blade.php b/resources/views/admin/machines/logs.blade.php index 8cf05dc..b201a14 100644 --- a/resources/views/admin/machines/logs.blade.php +++ b/resources/views/admin/machines/logs.blade.php @@ -1,29 +1,26 @@ @extends('layouts.admin') -@section('header') -
-

- {{ __('所有機台日誌') }} -

-
-@endsection +@section('title', __('Machine Logs')) @section('content') -
-
- - -
-
-

- 條件篩選 -

-
-
-
- - + @foreach($machines as $machine)
-
- - + + + +
-
- - -
-
- - - 重設 + +
+
- -
-
-

系統日誌清單

- 所有時間為系統時區 -
- -
-
{{ __('User Info') }}{{ __('Belongs To') }}{{ __('Role') }}{{ __('Status') }}{{ __('Actions') }}
{{ __('User Info') }}{{ __('Belongs To') }}{{ __('Role') }}{{ __('Status') }}{{ __('Actions') }}
-
+
@if($user->avatar) @else - {{ substr($user->name, 0, 1) }} + {{ substr($user->name, 0, 1) }} @endif
{{ $user->name }} - {{ $user->username }} @if($user->email) • {{ $user->email }} @endif + {{ $user->username }} @if($user->email) • {{ $user->email }} @endif
@foreach($user->roles as $role) - + {{ $role->name }} @endforeach @if($user->status) - + + {{ __('Active') }} @else - + {{ __('Disabled') }} @endif
- -
+ @csrf @method('DELETE') -
@@ -147,7 +146,10 @@ @empty
-

{{ __('No users found') }}

+
+ +

{{ __('No accounts found') }}

+
- - - - - - - - - - @forelse ($logs as $log) - - - - + + @empty + + + + @endforelse + +
時間機台層級訊息
{{ $log->created_at->format('Y-m-d H:i:s') }} - - {{ $log->machine->name ?? '未知機台' }} - - - @php - $levelClasses = [ - 'info' => 'text-cyan-600 dark:text-cyan-400 bg-cyan-50 dark:bg-cyan-500/20 border-cyan-200 dark:border-cyan-500/30', - 'warning' => 'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/20 border-amber-200 dark:border-amber-500/30 font-semibold', - 'error' => 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/20 border-rose-200 dark:border-rose-500/30 font-bold', - ]; - @endphp - - {{ strtoupper($log->level) }} +
+ + + + + + + + + + + @forelse ($logs as $log) + + + - + + - - @empty - - - - @endforelse - -
{{ __('Timestamp') }}{{ __('Machine') }}{{ __('Level') }}{{ __('Message Content') }}
+
+ {{ $log->created_at->format('Y-m-d') }} +
+
+ {{ $log->created_at->format('H:i:s') }} +
+
+ + + {{ $log->machine->name ?? __('Unknown') }} - + + + + @php + $badgeStyles = [ + 'info' => 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20', + 'warning' => 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20', + 'error' => 'bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20', + ]; + $currentStyle = $badgeStyles[$log->level] ?? 'bg-slate-500/10 text-slate-600 border-slate-500/20'; + @endphp + + + {{ __(ucfirst($log->level)) }} + + +

{{ $log->message }} - @if($log->context) -

- {{ json_encode($log->context, JSON_UNESCAPED_UNICODE) }} -
- @endif -
暫無相關日誌
-
- - @if($logs->hasPages()) -
- {{ $logs->links('vendor.pagination.luxury') }} -
- @endif +

+ @if($log->context) +
+
{{ json_encode($log->context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) }}
+
+ @endif +
+
+
+ +
+ {{ __('No matching logs found') }} +
+
+
+ +
+ {{ $logs->links('vendor.pagination.luxury') }}
-@endsection +@endsection \ No newline at end of file diff --git a/resources/views/admin/permission/roles.blade.php b/resources/views/admin/permission/roles.blade.php index 4e71e12..ac2d728 100644 --- a/resources/views/admin/permission/roles.blade.php +++ b/resources/views/admin/permission/roles.blade.php @@ -29,9 +29,10 @@
- -
-
+ +
+ +
@@ -43,30 +44,27 @@
-
- -
- +
- - - - - - + + + + + + - + @forelse($roles as $role) - - + - - - -
{{ __('Role Name') }}{{ __('Type') }}{{ __('Permissions') }}{{ __('Users') }}{{ __('Actions') }}
{{ __('Role Name') }}{{ __('Type') }}{{ __('Permissions') }}{{ __('Users') }}{{ __('Actions') }}
+
-
+
- {{ $role->name }} + {{ $role->name }} @if($role->is_system) @@ -74,42 +72,42 @@ @endif
+ @if($role->is_system) - + {{ __('System') }} @else - + {{ __('Custom') }} @endif +
- @forelse($role->permissions->take(5) as $permission) - {{ __(str_replace('menu.', '', $permission->name)) }} + @forelse($role->permissions->take(6) as $permission) + {{ __(str_replace('menu.', '', $permission->name)) }} @empty - {{ __('No permissions') }} + {{ __('No permissions') }} @endforelse - @if($role->permissions->count() > 5) - +{{ $role->permissions->count() - 5 }} + @if($role->permissions->count() > 6) + +{{ $role->permissions->count() - 6 }} @endif
+ {{ $role->users()->count() }} -
-
+
+ @if(!$role->is_system)
@csrf @method('DELETE') -
diff --git a/resources/views/components/breadcrumbs.blade.php b/resources/views/components/breadcrumbs.blade.php index f242f1f..edecb6b 100644 --- a/resources/views/components/breadcrumbs.blade.php +++ b/resources/views/components/breadcrumbs.blade.php @@ -80,7 +80,7 @@ // 3. 處理最後一個動作/頁面 if ($lastSegment !== 'index') { $pageLabel = match($lastSegment) { - 'edit' => __('Edit'), + 'edit' => str_starts_with($routeName, 'profile') ? null : __('Edit'), 'create' => __('Create'), 'show' => __('Detail'), 'logs' => __('Machine Logs'), diff --git a/routes/api.php b/routes/api.php index fc3a44e..9e8d326 100644 --- a/routes/api.php +++ b/routes/api.php @@ -47,6 +47,20 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () { |-------------------------------------------------------------------------- | 專門用於機台通訊,頻率較高,建議搭配異步處理。 */ + Route::prefix('app')->middleware(['iot.auth', 'throttle:100,1'])->group(function () { + // 心跳與狀態 (B010, B017, B710, B220) + Route::post('machine/status/B010', [App\Http\Controllers\Api\V1\App\MachineController::class, 'heartbeat']); + Route::post('machine/reload_msg/B017', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getSlots']); + Route::post('machine/timer/B710', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncTimer']); + Route::post('machine/coins/B220', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncCoinInventory']); + Route::post('machine/member/verify/B650', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyMember']); + + // 交易、發票與出貨 (B600, B601, B602) + Route::post('B600', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'store']); + Route::post('B601', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'recordInvoice']); + Route::post('B602', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'recordDispense']); + }); + Route::prefix('machines')->group(function () { Route::post('/{id}/logs', [\App\Http\Controllers\Api\V1\MachineController::class, 'storeLog']); });