Compare commits

..

4 Commits

Author SHA1 Message Date
c343df34ee [FEAT] 實作 B012 商品同步 API 與統一圖片絕對網址格式
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 58s
1. 實作 B012 API:新增 /api/v1/app/machine/products/B012 端點,支援 GET (全量) 與 PATCH (增量) 同步邏輯。
2. 統一圖片 URL:在 B005 與 B012 API 中使用 asset() 確保回傳絕對網址 (Absolute URL),解決 App 端下載相對路徑的問題。
3. 文件更新:同步更新 SKILL.md 的欄位定義,並在 api-docs.php 補上 B012 的正式規格說明。
4. 資料庫變更:新增 machine_slots 表的 type 欄位與相關註解遷移。
5. 格式優化:為技術規格文件中的 API 欄位與狀態碼加上反引號,提升文件中心可讀性。
2026-04-07 17:05:28 +08:00
253ae8afd4 [FEAT] 實作機台序號編輯功能與多語系支援
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 49s
1. 更新機台控制器 (MachineController),在更新時執行 serial_no 的必填與唯一性驗證。
2. 修改機台管理首頁 (index.blade.php),在快速編輯彈窗中加入機台序號輸入欄位。
3. 修正基礎設定中機台編輯頁面 (edit.blade.php),將原本唯讀的機台序號欄位改為可編輯輸入框,並加入必填標記。
4. 補齊並統一繁體中文、英文、日文翻譯檔中關於「機台序號」的翻譯 Key。
2026-04-07 14:55:24 +08:00
f2147ae6c4 [FEAT] 完善 IoT API 規范化、機台管理介面優化與 B005 改為 GET
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m4s
1. 將 B005 (廣告同步) 從 POST 改為 GET,符合 RESTful 規範。
2. 完善 B009 (庫存回報) 回應規格,加入業務代碼 (200 OK)。
3. API 文件 UI 優化:新增 Method Badge (方法標籤),並修正 JSON 中文/斜線轉義問題。
4. 機台管理介面優化:實作「唯讀庫存與效期」面板,並將日誌圖示改為「👁️」。
5. 標準化 ID 識別邏輯:資料表全面移除對 sku 的依賴,改以 id 為主、barcode 為輔。
6. 新增 Migration:正式移除 sku 欄位並同步 barcode 指向。
7. 更新多語系支援 (zh_TW, en, ja)。
2026-04-07 14:37:57 +08:00
b60afc3abe [FIX] 修正與標準化 B005 廣告下載 API 以相容既有 Android App
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m7s
1. 修改 MachineController@getAdvertisements 回傳欄位,將 t070v02 改為播放秒數,t070v03 改為位置代碼 (Flag)。
2. 根據 Android App 原始碼分析結果,調整位置對應:3 為待機廣告 (HomeActivity),1 為販賣頁廣告 (FontendActivity)。
3. 在 AdvertisementController@getMachineAds 增加 sort_order 排序,確保後台管理介面視圖與 API 輸出順序一致。
4. 更新廣告下載 API 的技術文件 (SKILL.md),明確標記欄位用途與位置代碼。
5. 在 routes/api.php 補上 B005 與 B009 的路由定義。
2026-04-07 11:41:24 +08:00
19 changed files with 964 additions and 80 deletions

View File

@@ -8,12 +8,12 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
本文件集中定義所有機台與雲端通訊的 API 規格,確保硬體端與軟體端在資料交換格式與業務定義上保持完全一致。 本文件集中定義所有機台與雲端通訊的 API 規格,確保硬體端與軟體端在資料交換格式與業務定義上保持完全一致。
## 1. 核心命名原則 ## 1. 核心命名原則
- **語意化優先**:捨棄舊版 `M_` 前綴,統一使用 snake_case (如 `firmware_version`)。 - **語意化優先**:捨棄舊版 M_ 前綴,統一使用 snake_case (如 firmware_version)。
- **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。 - **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。
## 2. 身份認證 (Authentication) ## 2. 身份認證 (Authentication)
- **Bearer Token**:所有 API 必須在 Header 帶入 `Authorization: Bearer <api_token>` - **Bearer Token**:所有 API 必須在 Header 帶入 Authorization: Bearer <api_token>。
- **身分綁定**:後端透過 Token 自動識別 `machine_id`,禁止在 Body 帶入 `machine``key` 欄位。 - **身分綁定**:後端透過 Token 自動識別 machine_id禁止在 Body 帶入 machine 或 key 欄位。
--- ---
@@ -22,76 +22,156 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
### 3.1 B000: 機台本地管理員同步登入 ### 3.1 B000: 機台本地管理員同步登入
用於機台 Android 端維護人員登入與進入設定頁。此 API 無狀態,且為例外不強制檢查 Bearer Token 的端點。 用於機台 Android 端維護人員登入與進入設定頁。此 API 無狀態,且為例外不強制檢查 Bearer Token 的端點。
- **URL**: `POST /api/v1/app/admin/login/B000` - **URL**: POST /api/v1/app/admin/login/B000
- **Request Body:** - **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 | | 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| `machine` | String | 是 | 機台編號 (serial_no) | `M-001` | | machine | String | 是 | 機台編號 (serial_no) | M-001 |
| `Su_Account` | String | 是 | 系統管理員或公司管理員帳號 | `admin` | | Su_Account | String | 是 | 系統管理員或公司管理員帳號 | admin |
| `Su_Password` | String | 是 | 密碼 | `password123` | | Su_Password | String | 是 | 密碼 | password123 |
| `ip` | String | 否 | 用戶端 IP (相容舊版) | `192.168.1.100` | | ip | String | 否 | 用戶端 IP (相容舊版) | 192.168.1.100 |
| `type` | String | 否 | 裝置類型代碼 (相容舊版) | `2` | | type | String | 否 | 裝置類型代碼 (相容舊版) | 2 |
- **Response Body:** - **Response Body:**
> [!IMPORTANT] > [!IMPORTANT]
> 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 `Success` > 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 Success。
| 參數 | 類型 | 說明 | 範例 | | 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| `message` | String | 驗證結果 (`Success``Failed`) | `Success` | | message | String | 驗證結果 (Success 或 Failed) | Success |
--- ---
### 3.2 B010: 心跳上報與狀態同步 ### 3.2 B005: 廣告清單同步
用於確認機台在線狀態、更新感測數據、提交事件日誌並獲取雲端指令 用於機台端獲取目前應播放的廣告檔案 URL 清單
- **URL**: `POST /api/v1/app/machine/status/B010` - **URL**: GET /api/v1/app/machine/ad/B005
- **Request Body:** - **Request Body:** 無 (GET 請求)
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| `current_page` | Integer | 是 | 當前頁面代碼 (見下表) | `1` |
| `firmware_version` | String | 是 | 韌體版本號 | `1.0.5` |
| `model` | String | 是 | 機台型號 | `STAR-V1` |
| `temperature` | Float | 否 | 環境溫度 | `25.5` |
| `door_status` | Integer | 否 | 門狀態 (0:關 / 1:開) | `0` |
| `log` | String | 否 | 事件日誌簡述 | `Door opened` |
| `log_level` | String | 否 | info, warn, error | `info` |
| `log_payload` | Object | 否 | 額外日誌 JSON 對象 | `{"code":500}` |
- **Response Body:** - **Response Body:**
| 參數 | 類型 | 說明 | 範例 | | 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| `success` | Boolean | 請求是否處理成功 | `true` | | success | Boolean | 請求是否成功 | true |
| `code` | Integer | 內部業務狀態碼 | `200` | | code | Integer | 內部業務狀態碼 | 200 |
| `message` | String | 回應訊息 | `OK` | | data | Array | 廣告物件陣列 | [{"t070v04": "https://..."}] |
| `status` | String | **雲端指令代碼** (見下表) | `49` |
**data 陣列內部欄位:**
- t070v01: 廣告名稱 (Name)
- t070v02: 播放長度 (Duration) — 秒數,若後台未設定,預設為 15 秒。
- t070v03: 廣告位置 (Position/Flag) — (3: 待機廣告, 1: 販賣頁, 2: 來店禮)。
- t070v04: 廣告 URL。
- t070v05: 播放順位 (Sort Order)。
---
### 3.3 B009: 貨道庫存即時回報 (Supplementary Report)
當維修或補貨人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。
#### B009 權限驗證邏輯 (RBAC Compliance)
系統會依據 account 欄位進行三層式權限核查:
1. **系統層 (System Admin)**:當 company_id 為 null 時,具備全局管理權限,直接放行。
2. **公司層 (Company Admin)**:當 is_admin 為 true 時,檢查機台的 company_id 是否與該帳號一致。
3. **人員層 (Operator/User)**:當帳號僅為一般人員時,檢查 machine_user 授權表,確認該帳號有被分配至此機台。
- **URL**: PUT /api/v1/app/products/supplementary/B009
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| account | String | 是 | 操作人員帳號 | 0999123456 |
| data | Array | 是 | 貨道數據陣列 | [{"tid":"1", "t060v00":"1", "num":"10"}] |
- **data 陣列內部欄位:**
| 欄位 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| tid | Integer | 貨道編號 (Slot No) | 1 |
| t060v00 | String | 商品資料庫 ID (或是 Barcode) | "1" |
| num | Integer | 實體剩餘庫存數量 | 10 |
| type | Integer | 貨道物理類型 (1: 履帶, 2: 彈簧)。若未提供,預設為 1。 | 1 |
> [!TIP]
> **自動化上限同步邏輯**
> 當後端收到 B009 時,會根據 type 自動從該商品的配置中選取 spring_limit 或 track_limit 並自動更新該貨道的 max_stock 欄位。機台端無需手動計算上限。
- **Response Body (Success 200):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 同步是否成功 | true |
| code | Integer | 內部業務狀態碼 | 200 |
| message | String | 回應訊息 | Slot report synchronized success |
| status | String | 固定回傳 49 代表同步完成 | "49" |
---
### 3.4 B010: 心跳上報與狀態同步
用於確認機台在線狀態、更新感測數據、提交事件日誌並獲取雲端指令。
- **URL**: POST /api/v1/app/machine/status/B010
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| current_page | Integer | 是 | 當前頁面代碼 (見下表) | 1 |
| firmware_version | String | 是 | 韌體版本號 | 1.0.5 |
| model | String | 是 | 機台型號 | STAR-V1 |
| temperature | Float | 否 | 環境溫度 | 25.5 |
| door_status | Integer | 否 | 門狀態 (0:關 / 1:開) | 0 |
| log | String | 否 | 事件日誌簡述 | Door opened |
| log_level | String | 否 | info, warn, error | info |
| log_payload | Object | 否 | 額外日誌 JSON 對象 | {"code":500} |
- **Response Body:**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否處理成功 | true |
| code | Integer | 內部業務狀態碼 | 200 |
| message | String | 回應訊息 | OK |
| status | String | **雲端指令代碼** (見下表) | 49 |
#### B010 代碼對照表 #### B010 代碼對照表
**頁面代碼 (current_page)** **頁面代碼 (current_page)**
- `0`: 離線 / `1`: 主頁面 / `2`: 販賣頁 / `3`: 管理頁 - 0: 離線 / 1: 主頁面 / 2: 販賣頁 / 3: 管理頁
- `4`: 補貨頁 / `5`: 教學頁 / `6`: 購買中 / `7`: 鎖定頁 - 4: 補貨頁 / 5: 教學頁 / 6: 購買中 / 7: 鎖定頁
- `60`: 出貨成功 / `61`: 貨道測試 / `62`: 付款選擇 - 60: 出貨成功 / 61: 貨道測試 / 62: 付款選擇
- `63`: 等待付款 / `64`: 出貨 / `65`: 收據簽單 - 63: 等待付款 / 64: 出貨 / 65: 收據簽單
- `66`: 通行碼 / `67`: 取貨碼 / `68`: 訊息顯示 - 66: 通行碼 / 67: 取貨碼 / 68: 訊息顯示
- `69`: 取消購買 / `610`: 購買結束 / `611`: 來店禮 - 69: 取消購買 / 610: 購買結束 / 611: 來店禮
- `612`: 出貨失敗 - 612: 出貨失敗
**雲端指令代碼 (status)** **雲端指令代碼 (status)**
- `49`: reload B017 (貨道同步) - 49: reload B017 (貨道同步)
- `51`: reboot (重啟系統) - 51: reboot (重啟系統)
- `60`: reboot card machine (刷卡機重啟) - 60: reboot card machine (刷卡機重啟)
- `61`: checkout (觸發結帳) - 61: checkout (觸發結帳)
- `70`: unlock (解鎖) - 70: unlock (解鎖)
- `71`: lock (鎖定) - 71: lock (鎖定)
- `85`: reload B0552 (遠端出貨) - 85: reload B0552 (遠端出貨)
- `待定義`: change (遠端找零 - 註:指令中心有此功能,但目前 Java App 尚無對接對應的連動事件)
--- ---
### 3.3 B017: 貨道與庫存同步 (規劃中) ### 3.5 B012: 商品配置與商品主檔同步 (Unified Sync)
- **URL**: `POST /api/v1/app/machine/reload_msg/B017` 用於機台端獲取目前所有可販售商品的詳細配置。App 端應依據呼叫的方法決定數據處理方式。
- 說明:當機台收到 B010 回應 `status: 49` 時,應呼叫此 API 同步最新貨道佈局。
### 3.4 B600: 交易數據回傳 (規劃中) - **URL**: GET|PATCH /api/v1/app/machine/products/B012
- **URL**: `POST /api/v1/app/B600` - **Authentication**: Bearer Token (Header)
- 說明:交易完成後提交支付方式、金額、商品與出貨結果。 - **Request Body:** 無 (由 Token 自動識別機台)
#### 運作邏輯 (Client-side Logic):
- **GET**:執行 **全量同步**。App 應於收到成功回應後,先執行 deleteAll() 再進行 insertAll() 以確保與伺服器完全一致。
- **PATCH**:執行 **增量更新**。App 於收到成功回應後,僅對記憶體中的既存商品進行欄位值覆蓋 (Patching)。
| 欄位 | 型別 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| t060v00 | String | 商品資料庫 ID | "1" |
| t060v01 | String | 商品名稱 | 可口可樂 330ml |
| t060v01_en | String | 英文名稱 | Coca Cola |
| t060v01_jp | String | 日文名稱 | コカコーラ |
| t060v03 | String | 商品規格/簡述 | Cold Drink |
| t060v06 | String | 圖片 URL | https://.../coke.png |
| t060v09 | Float | 標準零售價 | 25.0 |
| t060v11 | Integer | **貨道庫存上限** (預設履帶) | 10 |
| t060v30 | Float | 會員價 | 20.0 |
| t063v03 | Float | 本機銷售價格 (同定價) | 25.0 |
| t060v40 | String | 行銷計畫 (Marketing Plan) | Buy 1 Get 1 |
| t060v41 | String | 物料編碼 (Material Code) | SKU-001 |
| spring_limit | Integer | **彈簧貨道上限** (建議使用此欄位) | 10 |
| track_limit | Integer | **履帶貨道上限** (建議使用此欄位) | 15 |

View File

@@ -179,6 +179,7 @@ class AdvertisementController extends AdminController
{ {
$assignments = MachineAdvertisement::where('machine_id', $machine->id) $assignments = MachineAdvertisement::where('machine_id', $machine->id)
->with('advertisement') ->with('advertisement')
->orderBy('sort_order', 'asc')
->get() ->get()
->groupBy('position'); ->groupBy('position');

View File

@@ -5,9 +5,11 @@ namespace App\Http\Controllers\Api\V1\App;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\Machine\Machine; use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Jobs\Machine\ProcessHeartbeat; use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessTimerStatus; use App\Jobs\Machine\ProcessTimerStatus;
use App\Jobs\Machine\ProcessCoinInventory; use App\Jobs\Machine\ProcessCoinInventory;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
class MachineController extends Controller class MachineController extends Controller
@@ -104,7 +106,7 @@ class MachineController extends Controller
{ {
$machine = $request->get('machine'); $machine = $request->get('machine');
$slots = $machine->slots()->with('product')->get(); $slots = $machine->slots()->with('product')->get();
// 自動轉 Success: 若機台來撈 B017代表之前的 reload_stock 指令已成功被機台響應 // 自動轉 Success: 若機台來撈 B017代表之前的 reload_stock 指令已成功被機台響應
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id) \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'reload_stock') ->where('command_type', 'reload_stock')
@@ -194,4 +196,180 @@ class MachineController extends Controller
] ]
]); ]);
} }
/**
* B005: Download Machine Advertisements (Synchronous)
*/
public function getAdvertisements(Request $request)
{
$machine = $request->get('machine');
$advertisements = \App\Models\Machine\MachineAdvertisement::where('machine_id', $machine->id)
->with([
'advertisement' => function ($query) {
$query->active();
}
])
->get()
->filter(fn($ma) => $ma->advertisement !== null)
->map(function ($ma) {
// 定義顯示順序權重 (待機 > 購物 > 成功禮)
$posWeight = [
'standby' => 1,
'vending' => 2,
'visit_gift' => 3
];
// 為了相容現有機台 App 邏輯:
// App 讀取 t070v03 作為位置標籤 (flag)
// 1. HomeActivity (待機) 讀取 "3"
// 2. FontendActivity (販賣頁) 讀取 "1"
$posIdMap = [
'standby' => '3',
'vending' => '1',
'visit_gift' => '2'
];
return [
't070v01' => $ma->advertisement->name,
't070v02' => (string) ($ma->advertisement->duration ?? 15), // 秒數改放這裡
't070v03' => (string) ($posIdMap[$ma->position] ?? '1'), // 位置數字改放這裡 (App 會讀這欄當 Flag)
't070v04' => $ma->advertisement->url ? (str_starts_with($ma->advertisement->url, 'http') ? $ma->advertisement->url : asset($ma->advertisement->url)) : '',
't070v05' => (string) $ma->sort_order,
'raw_pos_weight' => $posWeight[$ma->position] ?? 99,
'raw_sort' => (int) $ma->sort_order
];
})
->sortBy([
['raw_pos_weight', 'asc'],
['raw_sort', 'asc']
])
->values()
->map(function ($item) {
unset($item['raw_pos_weight'], $item['raw_sort']);
return $item;
});
return response()->json([
'success' => true,
'code' => 200,
'data' => $advertisements->values()
]);
}
/**
* B009: Report Machine Slot List / Supplementary (Synchronous)
*/
public function reportSlotList(Request $request, \App\Services\Machine\MachineService $machineService)
{
$machine = $request->get('machine');
$payload = $request->all();
$account = $payload['account'] ?? null;
// 1. 驗證帳號是否存在 (驗證執行補貨的人員身分)
$user = User::where('username', $account)
->orWhere('email', $account)
->first();
if (!$user) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Unauthorized: Account not found',
'status' => ''
], 403);
}
// 2. 階層式權限驗證 (遵循 RBAC 多租戶規範)
$isAuthorized = false;
if ($user->isSystemAdmin()) {
// [系統層]:系統管理員可異動所有機台
$isAuthorized = true;
} elseif ($user->is_admin) {
// [公司層]:公司管理員需驗證此機台是否隸屬於該公司
$isAuthorized = ($machine->company_id === $user->company_id);
} else {
// [人員層]:一般人員需檢查 machine_user 授權表
$isAuthorized = $user->machines()->where('machine_id', $machine->id)->exists();
}
if (!$isAuthorized) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Unauthorized: Account not authorized for this machine',
'status' => ''
], 403);
}
// 3. 映射舊版機台回傳格式 (Map legacy machine format)
// 支持單個物件 data: {} 或 陣列 data: [{}] (Handle both single object and array)
$legacyData = $payload['data'] ?? [];
if (Arr::isAssoc($legacyData)) {
$legacyData = [$legacyData];
}
$mappedSlots = array_map(function ($item) {
return [
'slot_no' => $item['tid'] ?? null,
'product_id' => $item['t060v00'] ?? null,
'stock' => $item['num'] ?? 0,
'type' => $item['type'] ?? null,
];
}, $legacyData);
// 過濾無效資料 (Filter invalid entries)
$mappedSlots = array_filter($mappedSlots, fn($s) => $s['slot_no'] !== null);
// 同步處理更新庫存 (直接更新不進隊列)
$machineService->syncSlots($machine, $mappedSlots);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Slot report synchronized success',
'status' => '49'
]);
}
/**
* B012_new: Download Product Catalog (Synchronous)
*/
public function getProducts(Request $request)
{
$machine = $request->get('machine');
$products = \App\Models\Product\Product::where('company_id', $machine->company_id)
->with(['translations'])
->active()
->get()
->map(function ($product) {
// 提取多語系名稱 (Extract translations)
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
return [
't060v00' => (string) $product->id, // ID 仍建議維持字串,增加未來編號彈性
't060v01' => $product->name,
't060v01_en' => $nameEn,
't060v01_jp' => $nameJp,
't060v03' => $product->spec ?? '',
't060v06' => $product->image_url ? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url)) : '',
't060v09' => (float) $product->price,
't060v11' => (int) ($product->track_limit ?? 10),
't060v30' => (float) ($product->member_price ?? $product->price),
't060v40' => $product->metadata['marketing_plan'] ?? '', // 行銷計畫
't060v41' => $product->metadata['material_code'] ?? $product->barcode ?? '', // 物料編碼 (優先從 metadata 找,回退至條碼)
'spring_limit' => (int) ($product->spring_limit ?? 10),
'track_limit' => (int) ($product->track_limit ?? 10),
't063v03' => (float) $product->price,
];
});
return response()->json([
'success' => true,
'code' => 200,
'data' => $products
]);
}
} }

View File

@@ -14,6 +14,7 @@ class MachineSlot extends Model
'machine_id', 'machine_id',
'product_id', 'product_id',
'slot_no', 'slot_no',
'type',
'max_stock', 'max_stock',
'stock', 'stock',
'expiry_date', 'expiry_date',

View File

@@ -10,13 +10,20 @@ use App\Traits\TenantScoped;
class Product extends Model class Product extends Model
{ {
use HasFactory, SoftDeletes, TenantScoped; use HasFactory, SoftDeletes, TenantScoped;
/**
* Scope a query to only include active products.
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
protected $fillable = [ protected $fillable = [
'company_id', 'company_id',
'category_id', 'category_id',
'name', 'name',
'name_dictionary_key', 'name_dictionary_key',
'sku',
'barcode', 'barcode',
'spec', 'spec',
'manufacturer', 'manufacturer',

View File

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

View File

@@ -64,24 +64,50 @@ class MachineService
public function syncSlots(Machine $machine, array $slotsData): void public function syncSlots(Machine $machine, array $slotsData): void
{ {
DB::transaction(function () use ($machine, $slotsData) { DB::transaction(function () use ($machine, $slotsData) {
// 蒐集所有傳入的商品 ID (可能是 SKU 或 實際 ID)
$productCodes = collect($slotsData)->pluck('product_id')->filter()->unique()->toArray();
// 優先以 ID 查詢商品,若 ID 不存在則嘗試 Barcode (Prioritize ID lookup, fallback to Barcode)
$products = \App\Models\Product\Product::whereIn('id', $productCodes)
->orWhereIn('barcode', $productCodes)
->get();
foreach ($slotsData as $slotData) { foreach ($slotsData as $slotData) {
$slotNo = $slotData['slot_no'] ?? null; $slotNo = $slotData['slot_no'] ?? null;
if (!$slotNo) continue; if (!$slotNo) continue;
$existingSlot = $machine->slots()->where('slot_no', $slotNo)->first(); $existingSlot = $machine->slots()->where('slot_no', $slotNo)->first();
// 查找對應的實體 ID (支援 ID 與 Barcode 比對)
$productCode = $slotData['product_id'] ?? null;
$actualProductId = null;
if ($productCode) {
$actualProductId = $products->first(function ($p) use ($productCode) {
return (string)$p->id === (string)$productCode || $p->barcode === (string)$productCode;
})?->id;
}
// 根據貨道類型自動決定上限 (Auto-calculate max_stock based on slot type)
// 若未提供 type預設為 '1' (履帶/Track)
$slotType = $slotData['type'] ?? $existingSlot->type ?? '1';
if ($actualProductId) {
$product = $products->find($actualProductId);
if ($product) {
// 1: 履帶, 2: 彈簧
$calculatedMaxStock = ($slotType == '1') ? $product->track_limit : $product->spring_limit;
$slotData['capacity'] = $calculatedMaxStock ?? $slotData['capacity'] ?? null;
}
}
$updateData = [ $updateData = [
'product_id' => $slotData['product_id'] ?? null, 'product_id' => $actualProductId,
'type' => $slotType,
'stock' => $slotData['stock'] ?? 0, 'stock' => $slotData['stock'] ?? 0,
'capacity' => $slotData['capacity'] ?? ($existingSlot->capacity ?? 10), 'max_stock' => $slotData['capacity'] ?? ($existingSlot->max_stock ?? 10),
'price' => $slotData['price'] ?? ($existingSlot->price ?? 0), 'is_active' => true,
'last_restocked_at' => now(),
]; ];
// 如果商品變了,或者這是一次明確的補貨回報,清空效期等待管理員更新 // 如果這是一次明確的補貨回報,建議更新時間並記錄
// 這裡我們暫定只要有 report 進來,就需要重新確認效期
$updateData['expiry_date'] = null;
if ($existingSlot) { if ($existingSlot) {
$existingSlot->update($updateData); $existingSlot->update($updateData);
} else { } else {

View File

@@ -42,7 +42,7 @@ class TransactionService
$order->items()->create([ $order->items()->create([
'product_id' => $item['product_id'], 'product_id' => $item['product_id'],
'product_name' => $item['product_name'] ?? 'Unknown', 'product_name' => $item['product_name'] ?? 'Unknown',
'sku' => $item['sku'] ?? null, 'barcode' => $item['barcode'] ?? null,
'price' => $item['price'], 'price' => $item['price'],
'quantity' => $item['quantity'], 'quantity' => $item['quantity'],
'subtotal' => $item['price'] * $item['quantity'], 'subtotal' => $item['price'] * $item['quantity'],

View File

@@ -8,6 +8,119 @@ return [
[ [
'name' => '機台核心通訊 (IoT Core)', 'name' => '機台核心通訊 (IoT Core)',
'apis' => [ 'apis' => [
[
'name' => 'B005: 廣告清單同步 (Ad Sync)',
'slug' => 'b005-ad-sync',
'method' => 'GET',
'path' => '/api/v1/app/machine/ad/B005',
'description' => '用於機台端獲取目前應播放的廣告檔案 URL 清單。此介面無需 Request Body。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
'data' => [
'type' => 'array',
'description' => '廣告物件陣列。內部欄位包含t070v01 (名稱), t070v02 (秒數), t070v03 (位置1:販賣頁, 2:來店禮, 3:待機廣告), t070v04 (URL), t070v05 (順位)',
'example' => [
[
't070v01' => '測試機台廣告',
't070v02' => 15,
't070v03' => 3,
't070v04' => 'https://example.com/ad1.mp4',
't070v05' => 1
]
]
],
],
'request' => [],
'response' => [
'success' => true,
'code' => 200,
'message' => 'OK',
'data' => [
[
't070v01' => '測試機台廣告',
't070v02' => 15,
't070v03' => 3,
't070v04' => 'https://example.com/ad1.mp4',
't070v05' => 1
]
]
],
],
[
'name' => 'B009: 貨道庫存即時回報 (Inventory Report)',
'slug' => 'b009-inventory-report',
'method' => 'PUT',
'path' => '/api/v1/app/products/supplementary/B009',
'description' => '當人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。需進行 RBAC 權限核查。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'account' => [
'type' => 'string',
'required' => true,
'description' => '操作人員帳號',
'example' => '0999123456'
],
'data' => [
'type' => 'array',
'required' => true,
'description' => '貨道數據陣列。tid: 貨道號, t060v00: 商品 ID, num: 庫存量',
'example' => [
['tid' => '1', 't060v00' => '1', 'num' => '10']
]
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '同步是否成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
'message' => [
'type' => 'string',
'description' => '回應訊息',
'example' => 'Slot report synchronized success'
],
'status' => [
'type' => 'string',
'description' => '固定回傳 49 代表同步完成',
'example' => '49'
],
],
'request' => [
'account' => '0999123456',
'data' => [
['tid' => '1', 't060v00' => '1', 'num' => '10']
]
],
'response' => [
'success' => true,
'code' => 200,
'message' => 'Slot report synchronized success',
'status' => '49'
],
],
[ [
'name' => 'B010: 心跳上報與狀態同步 (Heartbeat)', 'name' => 'B010: 心跳上報與狀態同步 (Heartbeat)',
'slug' => 'b010-heartbeat', 'slug' => 'b010-heartbeat',
@@ -118,6 +231,77 @@ return [
'status' => '49' 'status' => '49'
], ],
'notes' => '機台收到 B010 回應中的特定 `status` 代碼後,應根據對照表執行對應的指令動作或 API 呼叫 (如 B017)。若為空則代表無指令。' 'notes' => '機台收到 B010 回應中的特定 `status` 代碼後,應根據對照表執行對應的指令動作或 API 呼叫 (如 B017)。若為空則代表無指令。'
],
[
'name' => 'B012: 商品配置與商品主檔同步 (Unified Sync)',
'slug' => 'b012-unified-sync',
'method' => 'GET/PATCH',
'path' => '/api/v1/app/machine/products/B012',
'description' => '用於機台端獲取目前所有可販售商品的詳細配置。GET 為全量同步PATCH 為增量更新。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否處理成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
'data' => [
'type' => 'array',
'description' => '商品明細物件陣列',
'example' => [
[
't060v00' => '1',
't060v01' => '可口可樂 330ml',
't060v01_en' => 'Coca Cola',
't060v01_jp' => 'コカコーラ',
't060v03' => 'Cold Drink',
't060v06' => 'https://.../coke.png',
't060v09' => 25.0,
't060v11' => 10,
't060v30' => 20.0,
't063v03' => 25.0,
't060v40' => 'Buy 1 Get 1',
't060v41' => 'SKU-001',
'spring_limit' => 10,
'track_limit' => 15
]
]
]
],
'request' => [],
'response' => [
'success' => true,
'code' => 200,
'message' => 'OK',
'data' => [
[
't060v00' => '1',
't060v01' => '可口可樂 330ml',
't060v01_en' => 'Coca Cola',
't060v01_jp' => 'コカコーラ',
't060v03' => 'Cold Drink',
't060v06' => 'https://.../coke.png',
't060v09' => 25.0,
't060v11' => 10,
't060v30' => 20.0,
't063v03' => 25.0,
't060v40' => 'Buy 1 Get 1',
't060v41' => 'SKU-001',
'spring_limit' => 10,
'track_limit' => 15
]
]
],
'notes' => '運作邏輯 (Client-side Logic): GET 執行全量同步App 應於收到成功回應後,先執行 deleteAll() 再進行 insertAll()。PATCH 執行增量更新App 僅對記憶體中的既存商品進行欄位值覆蓋 (Patching)。'
] ]
] ]
] ]

View File

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

View File

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

View File

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

View File

@@ -463,6 +463,7 @@
"Machine Model Settings": "Machine Model Settings", "Machine Model Settings": "Machine Model Settings",
"Machine model updated successfully.": "Machine model updated successfully.", "Machine model updated successfully.": "Machine model updated successfully.",
"Machine Name": "Machine Name", "Machine Name": "Machine Name",
"Machine Serial No": "Machine Serial No",
"Machine Permissions": "Machine Permissions", "Machine Permissions": "Machine Permissions",
"Machine Reboot": "Machine Reboot", "Machine Reboot": "Machine Reboot",
"Machine Registry": "Machine Registry", "Machine Registry": "Machine Registry",
@@ -615,6 +616,7 @@
"No roles available": "No roles available", "No roles available": "No roles available",
"No roles found.": "No roles found.", "No roles found.": "No roles found.",
"No slots found": "No slots found", "No slots found": "No slots found",
"No slot data available": "No slot data available",
"No users found": "No users found", "No users found": "No users found",
"None": "None", "None": "None",
"Normal": "Normal", "Normal": "Normal",
@@ -904,6 +906,7 @@
"Stock": "Stock", "Stock": "Stock",
"Stock & Expiry": "Stock & Expiry", "Stock & Expiry": "Stock & Expiry",
"Stock & Expiry Management": "Stock & Expiry Management", "Stock & Expiry Management": "Stock & Expiry Management",
"Stock & Expiry Overview": "Stock & Expiry Overview",
"Stock Management": "Stock Management", "Stock Management": "Stock Management",
"Stock Quantity": "Stock Quantity", "Stock Quantity": "Stock Quantity",
"Stock:": "Stock:", "Stock:": "Stock:",
@@ -1025,6 +1028,7 @@
"Venue Management": "Venue Management", "Venue Management": "Venue Management",
"video": "video", "video": "video",
"View Details": "View Details", "View Details": "View Details",
"View Inventory": "View Inventory",
"View Logs": "View Logs", "View Logs": "View Logs",
"View More": "View More", "View More": "View More",
"Visit Gift": "Visit Gift", "Visit Gift": "Visit Gift",

View File

@@ -615,6 +615,7 @@
"No roles available": "利用可能な権限はありません", "No roles available": "利用可能な権限はありません",
"No roles found.": "権限が見つかりません。", "No roles found.": "権限が見つかりません。",
"No slots found": "スロットが見つかりません", "No slots found": "スロットが見つかりません",
"No slot data available": "スロットデータがありません",
"No users found": "ユーザーが見つかりません", "No users found": "ユーザーが見つかりません",
"None": "なし", "None": "なし",
"Normal": "通常", "Normal": "通常",
@@ -904,6 +905,7 @@
"Stock": "在庫", "Stock": "在庫",
"Stock & Expiry": "在庫と消費期限", "Stock & Expiry": "在庫と消費期限",
"Stock & Expiry Management": "在庫・期限管理", "Stock & Expiry Management": "在庫・期限管理",
"Stock & Expiry Overview": "在庫・期限一覧",
"Stock Management": "在庫管理", "Stock Management": "在庫管理",
"Stock Quantity": "在庫数", "Stock Quantity": "在庫数",
"Stock:": "在庫:", "Stock:": "在庫:",
@@ -1025,6 +1027,7 @@
"Venue Management": "会場管理", "Venue Management": "会場管理",
"video": "video", "video": "video",
"View Details": "詳細を見る", "View Details": "詳細を見る",
"View Inventory": "在庫を見る",
"View Logs": "ログを見る", "View Logs": "ログを見る",
"View More": "もっと見る", "View More": "もっと見る",
"Visit Gift": "来店ギフト", "Visit Gift": "来店ギフト",

View File

@@ -463,6 +463,7 @@
"Machine Model Settings": "機台型號設定", "Machine Model Settings": "機台型號設定",
"Machine model updated successfully.": "機台型號已成功更新。", "Machine model updated successfully.": "機台型號已成功更新。",
"Machine Name": "機台名稱", "Machine Name": "機台名稱",
"Machine Serial No": "機台序號",
"Machine Permissions": "機台權限", "Machine Permissions": "機台權限",
"Machine Reboot": "機台重啟", "Machine Reboot": "機台重啟",
"Machine Registry": "機台清冊", "Machine Registry": "機台清冊",
@@ -615,6 +616,7 @@
"No roles available": "目前沒有角色資料。", "No roles available": "目前沒有角色資料。",
"No roles found.": "找不到角色資料。", "No roles found.": "找不到角色資料。",
"No slots found": "未找到貨道資訊", "No slots found": "未找到貨道資訊",
"No slot data available": "尚無貨道資料",
"No users found": "找不到用戶資料", "No users found": "找不到用戶資料",
"None": "無", "None": "無",
"Normal": "正常", "Normal": "正常",
@@ -904,6 +906,7 @@
"Stock": "庫存", "Stock": "庫存",
"Stock & Expiry": "庫存與效期", "Stock & Expiry": "庫存與效期",
"Stock & Expiry Management": "庫存與效期管理", "Stock & Expiry Management": "庫存與效期管理",
"Stock & Expiry Overview": "庫存與效期一覽",
"Stock Management": "庫存管理單", "Stock Management": "庫存管理單",
"Stock Quantity": "庫存數量", "Stock Quantity": "庫存數量",
"Stock:": "庫存:", "Stock:": "庫存:",
@@ -1025,6 +1028,7 @@
"Venue Management": "場地管理", "Venue Management": "場地管理",
"video": "影片", "video": "影片",
"View Details": "查看詳情", "View Details": "查看詳情",
"View Inventory": "查看庫存",
"View Logs": "查看日誌", "View Logs": "查看日誌",
"View More": "查看更多", "View More": "查看更多",
"Visit Gift": "來店禮", "Visit Gift": "來店禮",

View File

@@ -102,8 +102,8 @@
<input type="text" name="name" value="{{ old('name', $machine->name) }}" class="luxury-input w-full" required> <input type="text" name="name" value="{{ old('name', $machine->name) }}" class="luxury-input w-full" required>
</div> </div>
<div> <div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Serial Number') }}</label> <label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Machine Serial No') }} <span class="text-rose-500">*</span></label>
<input type="text" value="{{ $machine->serial_no }}" class="luxury-input w-full bg-slate-50/50 dark:bg-slate-900/50 text-slate-400 cursor-not-allowed" readonly> <input type="text" name="serial_no" value="{{ old('serial_no', $machine->serial_no) }}" class="luxury-input w-full" required>
</div> </div>
<div> <div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Location') }}</label> <label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Location') }}</label>

View File

@@ -7,6 +7,7 @@
return { return {
showLogPanel: false, showLogPanel: false,
showEditModal: false, showEditModal: false,
showInventoryPanel: false,
editMachineId: '', editMachineId: '',
editMachineName: '', editMachineName: '',
activeTab: 'status', activeTab: 'status',
@@ -15,12 +16,14 @@
currentMachineName: '', currentMachineName: '',
logs: [], logs: [],
loading: false, loading: false,
inventoryLoading: false,
startDate: '', startDate: '',
endDate: '', endDate: '',
tab: 'list', tab: 'list',
viewMode: 'fleet', viewMode: 'fleet',
selectedMachine: null, selectedMachine: null,
slots: [], slots: [],
inventorySlots: [],
init() { init() {
const d = new Date(); const d = new Date();
@@ -79,11 +82,44 @@
finally { this.loading = false; } finally { this.loading = false; }
}, },
// 庫存一覽面板 (唯讀)
async openInventoryPanel(id, sn, name) {
this.currentMachineId = id;
this.currentMachineSn = sn;
this.currentMachineName = name;
this.inventorySlots = [];
this.showInventoryPanel = true;
this.inventoryLoading = true;
try {
const res = await fetch('/admin/machines/' + id + '/slots-ajax');
const data = await res.json();
if (data.success) {
this.inventorySlots = data.slots;
}
} catch (e) { console.error('openInventoryPanel error:', e); }
finally { this.inventoryLoading = false; }
},
getSlotColorClass(slot) {
if (!slot.expiry_date) return 'bg-slate-50/50 dark:bg-slate-800/50 text-slate-400 border-slate-200/60 dark:border-slate-700/50';
const todayStr = new Date().toISOString().split('T')[0];
const expiryStr = slot.expiry_date;
if (expiryStr < todayStr) {
return 'bg-rose-50/60 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-200 dark:border-rose-500/30 shadow-sm shadow-rose-500/5';
}
const diffDays = Math.round((new Date(expiryStr) - new Date(todayStr)) / 86400000);
if (diffDays <= 7) {
return 'bg-amber-50/60 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-500/30 shadow-sm shadow-amber-500/5';
}
return 'bg-emerald-50/60 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/30 shadow-sm shadow-emerald-500/5';
},
}; };
}; };
</script> </script>
<div class="space-y-4 pb-20 mt-4" x-data="machineApp()" @keydown.escape.window="showLogPanel = false"> <div class="space-y-4 pb-20 mt-4" x-data="machineApp()"
@keydown.escape.window="showLogPanel = false; showInventoryPanel = false">
<!-- Top Header & Actions --> <!-- Top Header & Actions -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@@ -243,6 +279,16 @@
</td> </td>
<td class="px-6 py-6 text-right"> <td class="px-6 py-6 text-right">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<button type="button"
@click="openInventoryPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip"
title="{{ __('View Inventory') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</button>
<button type="button" <button type="button"
@click="openEditModal('{{ $machine->id }}', '{{ addslashes($machine->name) }}')" @click="openEditModal('{{ $machine->id }}', '{{ addslashes($machine->name) }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip" class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip"
@@ -260,7 +306,9 @@
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" <svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" <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" /> d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg> </svg>
</button> </button>
</div> </div>
@@ -519,20 +567,21 @@
</div><!-- /Offcanvas --> </div><!-- /Offcanvas -->
<!-- Edit Machine Name Modal --> <!-- Edit Machine Name Modal -->
<div x-show="showEditModal" class="fixed inset-0 z-[100] overflow-y-auto" style="display: none;" role="dialog" aria-modal="true"> <div x-show="showEditModal" class="fixed inset-0 z-[100] overflow-y-auto" style="display: none;" role="dialog"
aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"> <div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<!-- Background Backdrop --> <!-- Background Backdrop -->
<div x-show="showEditModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" <div x-show="showEditModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200" x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity" @click="showEditModal = false"> class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
@click="showEditModal = false">
</div> </div>
<span class="hidden sm:inline-block sm:align-middle sm:min-h-screen" aria-hidden="true">&#8203;</span> <span class="hidden sm:inline-block sm:align-middle sm:min-h-screen" aria-hidden="true">&#8203;</span>
<!-- Modal Panel --> <!-- Modal Panel -->
<div x-show="showEditModal" <div x-show="showEditModal" x-transition:enter="ease-out duration-300"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200" x-transition:leave="ease-in duration-200"
@@ -541,15 +590,20 @@
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-[2.5rem] p-10 text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-slate-100 dark:border-slate-800"> class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-[2.5rem] p-10 text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-slate-100 dark:border-slate-800">
<div> <div>
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none mb-2">{{ __('Edit Machine Name') }}</h3> <h3
<p class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Update identification for your asset') }}</p> class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none mb-2">
{{ __('Edit Machine Name') }}</h3>
<p class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{
__('Update identification for your asset') }}</p>
<form :action="'/admin/machines/' + editMachineId" method="POST" class="mt-8 space-y-6"> <form :action="'/admin/machines/' + editMachineId" method="POST" class="mt-8 space-y-6">
@csrf @csrf
@method('PUT') @method('PUT')
<div class="space-y-4"> <div class="space-y-4">
<label class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.1em]">{{ __('New Machine Name') }}</label> <label
class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.1em]">{{
__('New Machine Name') }}</label>
<input type="text" name="name" x-model="editMachineName" required <input type="text" name="name" x-model="editMachineName" required
class="luxury-input block w-full px-6 py-4 text-base font-bold text-slate-800 dark:text-white bg-slate-50/50 dark:bg-slate-900/50" class="luxury-input block w-full px-6 py-4 text-base font-bold text-slate-800 dark:text-white bg-slate-50/50 dark:bg-slate-900/50"
placeholder="{{ __('Enter machine name...') }}"> placeholder="{{ __('Enter machine name...') }}">
@@ -571,4 +625,228 @@
</div> </div>
</div><!-- /Edit Modal --> </div><!-- /Edit Modal -->
<!-- Inventory Offcanvas Panel (唯讀庫存一覽) -->
<div x-show="showInventoryPanel" class="fixed inset-0 z-[100] overflow-hidden" style="display: none;"
aria-labelledby="inventory-panel-title" role="dialog" aria-modal="true">
<!-- Background backdrop -->
<div x-show="showInventoryPanel" x-transition:enter="ease-in-out duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in-out duration-300" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
@click="showInventoryPanel = false">
</div>
<div class="fixed inset-y-0 right-0 max-w-full flex">
<!-- Sliding panel -->
<div x-show="showInventoryPanel"
x-transition:enter="transform transition ease-in-out duration-500 sm:duration-700"
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition ease-in-out duration-500 sm:duration-700"
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
class="w-screen max-w-4xl">
<div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl">
<!-- Header -->
<div
class="px-5 py-6 sm:px-8 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<h2 id="inventory-panel-title"
class="text-xl sm:text-2xl font-black text-slate-800 dark:text-white font-display flex items-center gap-2 sm:gap-3">
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-cyan-500 flex-shrink-0"
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<span class="truncate">{{ __('Stock & Expiry Overview') }}</span>
</h2>
<div
class="mt-2 flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-[10px] sm:text-sm text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest overflow-hidden">
<span x-text="currentMachineSn"
class="font-mono text-cyan-600 dark:text-cyan-400 truncate"></span>
<span class="hidden sm:inline opacity-50"></span>
<span x-text="currentMachineName" class="truncate"></span>
</div>
</div>
<div class="flex-shrink-0 h-7 flex items-center">
<button type="button" @click="showInventoryPanel = false"
class="bg-white dark:bg-slate-800 rounded-full p-2 text-slate-400 hover:text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 transition duration-300 shadow-sm border border-slate-200 dark:border-slate-700">
<span class="sr-only">{{ __('Close Panel') }}</span>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- 統計摘要 -->
<div class="mt-6 flex items-center gap-4">
<div
class="px-5 py-3 rounded-2xl bg-white dark:bg-slate-800/50 flex flex-col items-center min-w-[100px] border border-slate-100 dark:border-slate-800/50">
<span class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{{
__('Total Slots') }}</span>
<span class="text-2xl font-black text-slate-700 dark:text-slate-200"
x-text="inventorySlots.length"></span>
</div>
<div
class="px-5 py-3 rounded-2xl bg-rose-500/5 border border-rose-500/10 flex flex-col items-center min-w-[100px]">
<span class="text-[9px] font-black text-rose-500 uppercase tracking-widest mb-0.5">{{
__('Low Stock') }}</span>
<span class="text-2xl font-black text-rose-600"
x-text="inventorySlots.filter(s => s != null && s.stock <= 5).length"></span>
</div>
<div
class="px-5 py-3 rounded-2xl bg-amber-500/5 border border-amber-500/10 flex flex-col items-center min-w-[100px]">
<span class="text-[9px] font-black text-amber-500 uppercase tracking-widest mb-0.5">{{
__('Expiring') }}</span>
<span class="text-2xl font-black text-amber-600"
x-text="inventorySlots.filter(s => { if (!s || !s.expiry_date) return false; const diff = Math.round((new Date(s.expiry_date) - new Date()) / 86400000); return diff >= 0 && diff <= 7; }).length"></span>
</div>
</div>
</div><!-- /Header -->
<!-- Body / Cabinet Grid -->
<div class="flex-1 overflow-y-auto p-6 sm:p-8">
<div class="relative min-h-[400px]">
<!-- Loading State -->
<div x-show="inventoryLoading"
class="absolute inset-0 bg-white/50 dark:bg-slate-900/50 backdrop-blur-[1px] flex items-center justify-center z-20">
<div class="flex flex-col items-center gap-3">
<div
class="w-8 h-8 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin">
</div>
<span class="text-xs font-black text-slate-400 uppercase tracking-widest">{{
__('Loading...') }}</span>
</div>
</div>
<!-- Status Legend -->
<div class="flex items-center gap-6 mb-6" x-show="!inventoryLoading">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-rose-500 shadow-lg shadow-rose-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{
__('Expired') }}</span>
</div>
<div class="flex items-center gap-2">
<span
class="w-3 h-3 rounded-full bg-amber-500 shadow-lg shadow-amber-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{
__('Warning') }}</span>
</div>
<div class="flex items-center gap-2">
<span
class="w-3 h-3 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{
__('Normal') }}</span>
</div>
</div>
<!-- Slots Grid (唯讀) -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5"
x-show="!inventoryLoading">
<template x-for="slot in inventorySlots" :key="slot.id">
<div :class="getSlotColorClass(slot)"
class="min-h-[260px] rounded-[2rem] p-5 flex flex-col items-center justify-center border-2 transition-all duration-300 relative">
<!-- Slot Header -->
<div
class="absolute top-3.5 left-4 right-4 flex justify-between items-center z-10">
<div
class="px-2.5 py-1 rounded-xl bg-slate-900/10 dark:bg-white/10 backdrop-blur-md border border-slate-900/5 dark:border-white/10 flex-shrink-0">
<span
class="text-xs font-black uppercase tracking-tighter text-slate-800 dark:text-white"
x-text="slot.slot_no"></span>
</div>
<template x-if="slot.stock <= 2">
<div
class="px-2 py-1 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 animate-pulse whitespace-nowrap select-none">
{{ __('Low') }}
</div>
</template>
</div>
<!-- Product Image -->
<div class="relative w-16 h-16 mb-3 mt-2">
<div
class="absolute inset-0 rounded-2xl bg-white/20 dark:bg-slate-900/40 backdrop-blur-xl border border-white/30 dark:border-white/5 shadow-inner overflow-hidden">
<template x-if="slot.product && slot.product.image_url">
<img :src="slot.product.image_url"
class="w-full h-full object-cover">
</template>
<template x-if="!slot.product || !slot.product.image_url">
<div class="w-full h-full flex items-center justify-center">
<svg class="w-7 h-7 opacity-20" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2.5"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
</template>
</div>
</div>
<!-- Slot Info -->
<div class="text-center w-full space-y-2">
<template x-if="slot.product">
<div class="text-sm font-black truncate w-full opacity-90 tracking-tight"
x-text="slot.product.name"></div>
</template>
<template x-if="!slot.product">
<div
class="text-sm font-bold text-slate-300 dark:text-slate-600 tracking-tight">
{{ __('Empty') }}</div>
</template>
<div class="space-y-2">
<!-- Stock Level -->
<div class="flex items-baseline justify-center gap-1">
<span class="text-xl font-black tracking-tighter leading-none"
x-text="slot.stock"></span>
<span class="text-xs font-black opacity-30">/</span>
<span class="text-sm font-bold opacity-50"
x-text="slot.max_stock || 10"></span>
</div>
<!-- Expiry Date -->
<div class="text-sm font-black tracking-tight leading-none opacity-80"
x-text="slot.expiry_date ? slot.expiry_date.replace(/-/g, '/') : '----/--/--'">
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Empty state -->
<template x-if="inventorySlots.length === 0 && !inventoryLoading">
<div class="px-6 py-20 text-center">
<div class="flex flex-col items-center">
<div
class="p-4 rounded-full bg-slate-50 dark:bg-slate-800/50 mb-4 border border-slate-100 dark:border-slate-800/50">
<svg class="w-8 h-8 text-slate-300 dark:text-slate-600" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.5">
<path
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<span
class="text-[11px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{
__('No slot data available') }}</span>
</div>
</div>
</template>
</div>
</div><!-- /Body -->
</div>
</div><!-- /Sliding panel -->
</div>
</div><!-- /Inventory Offcanvas -->
@endsection @endsection

View File

@@ -166,7 +166,12 @@
<a href="#{{ $api['slug'] }}" class="luxury-nav-link" <a href="#{{ $api['slug'] }}" class="luxury-nav-link"
:class="{ 'active': activeSection === '{{ $api['slug'] }}' }" :class="{ 'active': activeSection === '{{ $api['slug'] }}' }"
@click="activeSection = '{{ $api['slug'] }}'"> @click="activeSection = '{{ $api['slug'] }}'">
<span>{{ $api['name'] }}</span> <div class="flex items-center gap-2 overflow-hidden w-full">
<span class="flex-shrink-0 text-[10px] w-10 text-center font-black px-1.5 py-0.5 rounded-md uppercase tracking-tighter {{ $api['method'] === 'GET' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' : ($api['method'] === 'POST' ? 'bg-cyan-50 text-cyan-600 border border-cyan-200' : 'bg-amber-50 text-amber-600 border border-amber-200') }}">
{{ $api['method'] }}
</span>
<span class="truncate">{{ $api['name'] }}</span>
</div>
</a> </a>
@endforeach @endforeach
@endforeach @endforeach
@@ -187,7 +192,12 @@
<div id="{{ $api['slug'] }}" class="mb-16 animate-luxury-in" x-intersect="activeSection = '{{ $api['slug'] }}'"> <div id="{{ $api['slug'] }}" class="mb-16 animate-luxury-in" x-intersect="activeSection = '{{ $api['slug'] }}'">
<div class="mb-4"></div> <div class="mb-4"></div>
<h3 class="font-display text-3xl font-black text-slate-900 mb-6 tracking-tight">{{ $api['name'] }}</h3> <div class="flex items-center gap-4 mb-6">
<span class="px-3 py-1 text-sm font-black rounded-lg uppercase tracking-widest {{ $api['method'] === 'GET' ? 'bg-emerald-100 text-emerald-700' : ($api['method'] === 'POST' ? 'bg-cyan-100 text-cyan-700' : 'bg-amber-100 text-amber-700') }}">
{{ $api['method'] }}
</span>
<h3 class="font-display text-3xl font-black text-slate-900 tracking-tight">{{ $api['name'] }}</h3>
</div>
<p class="text-slate-600 mb-8 text-lg font-medium">{{ $api['description'] }}</p> <p class="text-slate-600 mb-8 text-lg font-medium">{{ $api['description'] }}</p>
<!-- Headers & URL --> <!-- Headers & URL -->
@@ -264,7 +274,7 @@
<span <span
class="text-[11px] font-black text-slate-400 uppercase tracking-tight">範例:</span> class="text-[11px] font-black text-slate-400 uppercase tracking-tight">範例:</span>
<code <code
class="text-sm text-cyan-600 font-bold">{{ is_array($param['example']) ? json_encode($param['example']) : $param['example'] }}</code> class="text-sm text-cyan-600 font-bold">{{ is_array($param['example']) ? json_encode($param['example'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $param['example'] }}</code>
</div> </div>
@endif @endif
</td> </td>
@@ -280,7 +290,7 @@
<!-- Request Example --> <!-- Request Example -->
<div> <div>
<h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-6">請求範例 (Request Body)</h4> <h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-6">請求範例 (Request Body)</h4>
<pre><code>{{ json_encode($api['request'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</code></pre> <pre><code>{{ json_encode($api['request'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) }}</code></pre>
</div> </div>
<!-- Response Examples --> <!-- Response Examples -->
@@ -310,7 +320,7 @@
@if(isset($param['example'])) @if(isset($param['example']))
<div class="mt-2 flex items-center gap-2"> <div class="mt-2 flex items-center gap-2">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-tight">範例:</span> <span class="text-[10px] font-black text-slate-400 uppercase tracking-tight">範例:</span>
<code class="text-xs text-cyan-600 font-bold bg-cyan-50/50 px-2 py-0.5 rounded">{{ $param['example'] }}</code> <code class="text-xs text-cyan-600 font-bold bg-cyan-50/50 px-2 py-0.5 rounded">{{ is_array($param['example']) ? json_encode($param['example'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $param['example'] }}</code>
</div> </div>
@endif @endif
</td> </td>
@@ -322,7 +332,7 @@
@endif @endif
<h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">回應範例 (Response Body)</h4> <h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">回應範例 (Response Body)</h4>
<pre><code>{{ json_encode($api['response'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</code></pre> <pre><code>{{ json_encode($api['response'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) }}</code></pre>
</div> </div>
@if(isset($api['notes'])) @if(isset($api['notes']))

View File

@@ -61,6 +61,13 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
Route::post('machine/coins/B220', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncCoinInventory']); 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']); Route::post('machine/member/verify/B650', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyMember']);
// 廣告與貨道清單 (B005, B009, B012)
Route::get('machine/ad/B005', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getAdvertisements']);
Route::put('products/supplementary/B009', [App\Http\Controllers\Api\V1\App\MachineController::class, 'reportSlotList']);
// 統一商品主檔 API (B012 整合版)
Route::match(['get', 'patch'], 'machine/products/B012', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getProducts']);
// 交易、發票與出貨 (B600, B601, B602) // 交易、發票與出貨 (B600, B601, B602)
Route::post('machine/restock/B018', [App\Http\Controllers\Api\V1\App\MachineController::class, 'recordRestock']); Route::post('machine/restock/B018', [App\Http\Controllers\Api\V1\App\MachineController::class, 'recordRestock']);
Route::post('B600', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'store']); Route::post('B600', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'store']);