diff --git a/.agents/skills/api-technical-specs/SKILL.md b/.agents/skills/api-technical-specs/SKILL.md index b7c6d74..3653e87 100644 --- a/.agents/skills/api-technical-specs/SKILL.md +++ b/.agents/skills/api-technical-specs/SKILL.md @@ -218,34 +218,55 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與 --- -### 3.9 B024: 取貨碼/通行碼驗證與消耗回報 (Access Code Verify & Report) -用於處理機台端的代碼取貨流程。包含驗證代碼有效性(驗證階段)與確認出貨完成後的狀態消耗(回報階段)。 +### 3.9 B660: 取貨碼驗證與消耗 (Pickup Code) +處理機台端的代碼取貨流程。採用兩階段流程以確保庫存準確性。 -- **URL**: POST|PUT /api/v1/app/sell/access-code/B024 +#### [B660] 取貨碼驗證與消耗 (兩階段) +* **Path**: `/api/v1/app/machine/pickup/verify/B660` +* **Method**: `POST` (驗證) / `PUT` (消耗) +* **Description**: 驗證 8 位數取貨碼,並於出貨完成後回報消耗以扣除庫存。 + +#### [B670] 通行碼驗證 (維護專用) +* **Path**: `/api/v1/app/machine/passcode/verify/B670` +* **Method**: `POST` +* **Description**: 工程人員現場測試專用通行碼,不涉及庫存消耗。 + +| 參數 | 類型 | 必填 | 說明 | 範例 | +| :--- | :--- | :--- | :--- | :--- | +| code | String | 是 | 使用者輸入的 8 位數取貨碼 | "12345678" | + +**Response (Success 200):** +- `pickup_code_id`: 代碼內部 ID,供第二階段 PUT 使用。 +- `slot_no`: 建議出貨貨道。 +- `product_name`: 商品名稱。 + +#### B. 消耗階段 (PUT) +當機台確認出貨成功後發送,雲端此時才正式執行 `decrement` 庫存與標記代碼已使用。 + +| 參數 | 類型 | 必填 | 說明 | 範例 | +| :--- | :--- | :--- | :--- | :--- | +| pickup_code_id | Integer | 是 | 驗證階段取得的 ID | 45 | +| status | Integer | 是 | 出貨結果 (1: 成功, 0: 失敗) | 1 | + +--- + +### 3.10 B670: 通行碼驗證 (Pass Code) +專供維修或工程人員現場測試使用的通行碼。 + +- **URL**: POST /api/v1/app/machine/passcode/verify/B670 - **Authentication**: Bearer Token (Header) -- **Request Body (POST - 驗證階段):** +- **Request Body:** | 參數 | 類型 | 必填 | 說明 | 範例 | | :--- | :--- | :--- | :--- | :--- | -| passCode | String | 是 | 使用者輸入的取貨碼/通行碼 | "12345678" | +| code | String | 是 | 8 位數通行碼 | "88888888" | -- **Response Body (POST - 驗證成功 200):** -雲端將回傳該碼對應的權限或待出貨商品資訊。 +**Response (Success 200):** +- `name`: 持有者姓名 (如:維護部 A 員)。 +- `status`: "active"。 -| 參數 | 類型 | 說明 | 範例 | -| :--- | :--- | :--- | :--- | -| res1 | String | 該代碼在雲端的關聯 ID | "99" | -| res2 | String | 操作模式 (1: 出貨, 2: 僅驗證) | "1" | -| res3 | String | 預計出貨商品 ID | "5" | -| res4 | String | 折扣金額或活動標籤 | "0.0" | - -- **Request Body (PUT - 回報階段):** -當機台端確認實體出貨成功後,必須發送此請求以耗刷該代碼。 - -| 參數 | 類型 | 必填 | 說明 | 範例 | -| :--- | :--- | :--- | :--- | :--- | -| accessCodeId | String | 是 | 驗證階段取得的 res1 (ID) | "99" | -| status | String | 是 | 出貨結果 (1: 成功, 0: 失敗) | "1" | +> [!WARNING] +> **鎖定機制**:B660 與 B670 若連續錯誤 5 次,該機台將被鎖定驗證功能 1 分鐘,以防暴力破解。 --- @@ -258,11 +279,28 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與 - **POST**: 提交 `passCode` 驗證該贈品券是否有效。成功後雲端會回傳對應活動 ID 與商品配置。 - **PUT**: 當該贈品(0 元商品)出貨完成後,向雲端回報消耗,確保優惠券不會重複核銷。 -> [!NOTE] -> B027 與 B024 的邏輯具備高度對稱性,區別在於 B027 通常綁定的是特定的行銷活動 (Campaign) 與 0 元出貨邏輯。 +--- + +### 3.11 B650: 會員身分驗證 (Member Verification) +機台端掃碼後驗證會員身分。 + +- **URL**: POST /api/v1/app/machine/member/verify/B650 +- **Authentication**: Bearer Token (Header) +- **Request Body:** + +| 參數 | 類型 | 必填 | 說明 | 範例 | +| :--- | :--- | :--- | :--- | :--- | +| code | String | 是 | 會員代碼 | "MEMBER12345" | --- +### 3.12 B710: 機台時間同步 (Timer Sync) +校準機台 RTC 時間。 + +- **URL**: POST /api/v1/app/machine/timer/B710 +- **Authentication**: Bearer Token (Header) +- **Response:** 回傳當前伺服器 Unix Timestamp。 + ## 4. 已遷移至 MQTT 的端點索引 diff --git a/app/Http/Controllers/Admin/MachineController.php b/app/Http/Controllers/Admin/MachineController.php index 029236b..e25ab8c 100644 --- a/app/Http/Controllers/Admin/MachineController.php +++ b/app/Http/Controllers/Admin/MachineController.php @@ -216,4 +216,20 @@ class MachineController extends AdminController { return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder } + + /** + * AJAX: 清除機台所有未解決的警告與異常 (手動排除) + */ + public function resolveLogs(Machine $machine) + { + $machine->logs() + ->where('is_resolved', false) + ->whereIn('level', ['error', 'warning']) + ->update(['is_resolved' => true]); + + return response()->json([ + 'success' => true, + 'message' => __('All issues marked as resolved.') + ]); + } } diff --git a/app/Http/Controllers/Admin/SalesController.php b/app/Http/Controllers/Admin/SalesController.php index 78f845f..498fd98 100644 --- a/app/Http/Controllers/Admin/SalesController.php +++ b/app/Http/Controllers/Admin/SalesController.php @@ -68,11 +68,46 @@ class SalesController extends Controller // 2. 操作紀錄 (logs) if (!$isAjax || $tab === 'logs') { - $data['logs'] = SystemOperationLog::with('user:id,name') - ->where('module', 'pickup_code') - ->latest() + $logQuery = SystemOperationLog::with('user:id,name') + ->where('module', 'pickup_code'); + + if ($request->filled('search_log')) { + $search = $request->input('search_log'); + $logQuery->where(function ($q) use ($search) { + $q->where('target_id', 'like', "%{$search}%") + ->orWhere('new_values', 'like', "%{$search}%") + ->orWhere('old_values', 'like', "%{$search}%"); + }); + } + + // 新增:類型篩選 + if ($request->filled('action')) { + $logQuery->where('action', $request->action); + } + + // 新增:日期區間篩選 + if ($request->filled('start_date') && $request->filled('end_date')) { + try { + $start = \Carbon\Carbon::parse($request->start_date)->startOfMinute(); + $end = \Carbon\Carbon::parse($request->end_date)->endOfMinute(); + $logQuery->whereBetween('created_at', [$start, $end]); + } catch (\Exception $e) { } + } + + $data['logs'] = $logQuery->latest() ->paginate($request->input('per_page', 15), ['*'], 'log_page') ->withQueryString(); + + // 定義可用動作 + $data['actions'] = [ + 'create' => __('create'), + 'update' => __('update'), + 'cancel' => __('cancel'), + 'used' => __('used'), + 'consume' => __('consume'), + 'consume_failed' => __('consume_failed'), + 'verify_success' => __('verify_success'), + ]; } if ($isAjax) { @@ -196,45 +231,103 @@ class SalesController extends Controller // 通行碼設定 public function passCodes(Request $request) { - $query = PassCode::with(['machine', 'creator'])->latest(); + $tab = $request->input('tab', 'list'); + $isAjax = $request->ajax(); - if ($request->search) { - $query->where(function ($q) use ($request) { - $q->where('code', 'like', "%{$request->search}%") - ->orWhere('name', 'like', "%{$request->search}%") - ->orWhereHas('machine', function ($mq) use ($request) { - $mq->where('name', 'like', "%{$request->search}%") - ->orWhere('serial_no', 'like', "%{$request->search}%"); - }); - }); - } - - if ($request->status && trim($request->status) !== '') { - $status = trim($request->status); - if ($status === 'active') { - $query->where('status', 'active') - ->where(function ($q) { - $q->whereNull('expires_at') - ->orWhere('expires_at', '>', now()); - }); - } elseif ($status === 'expired') { - $query->where('status', 'active') - ->whereNotNull('expires_at') - ->where('expires_at', '<=', now()); - } else { - $query->where('status', $status); - } - } - - $passCodes = $query->paginate(15)->withQueryString(); - $machines = Machine::all(); - - return view('admin.sales.pass-codes.index', [ + $data = [ 'title' => '通行碼設定', 'description' => '特殊通行權限碼管理 (測試/補貨用)', - 'passCodes' => $passCodes, - 'machines' => $machines, - ]); + 'tab' => $tab, + ]; + + // 1. 通行碼列表 (list) + if (!$isAjax || $tab === 'list') { + $query = PassCode::with(['machine', 'creator'])->latest(); + + if ($request->search) { + $query->where(function ($q) use ($request) { + $q->where('code', 'like', "%{$request->search}%") + ->orWhere('name', 'like', "%{$request->search}%") + ->orWhereHas('machine', function ($mq) use ($request) { + $mq->where('name', 'like', "%{$request->search}%") + ->orWhere('serial_no', 'like', "%{$request->search}%"); + }); + }); + } + + if ($request->status && trim($request->status) !== '') { + $status = trim($request->status); + if ($status === 'active') { + $query->where('status', 'active') + ->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } elseif ($status === 'expired') { + $query->where('status', 'active') + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()); + } else { + $query->where('status', $status); + } + } + + $data['passCodes'] = $query->paginate($request->input('per_page', 15), ['*'], 'list_page')->withQueryString(); + $data['machines'] = Machine::all(); + } + + // 2. 操作紀錄 (logs) + if (!$isAjax || $tab === 'logs') { + $logQuery = SystemOperationLog::with('user:id,name') + ->where('module', 'pass_code'); + + if ($request->filled('search_log')) { + $search = $request->input('search_log'); + $logQuery->where(function ($q) use ($search) { + $q->where('target_id', 'like', "%{$search}%") + ->orWhere('new_values', 'like', "%{$search}%") + ->orWhere('old_values', 'like', "%{$search}%"); + }); + } + + // 新增:類型篩選 + if ($request->filled('action')) { + $logQuery->where('action', $request->action); + } + + // 新增:日期區間篩選 + if ($request->filled('start_date') && $request->filled('end_date')) { + try { + $start = \Carbon\Carbon::parse($request->start_date)->startOfMinute(); + $end = \Carbon\Carbon::parse($request->end_date)->endOfMinute(); + $logQuery->whereBetween('created_at', [$start, $end]); + } catch (\Exception $e) { } + } + + $data['logs'] = $logQuery->latest() + ->paginate($request->input('per_page', 15), ['*'], 'log_page') + ->withQueryString(); + + // 定義可用動作 + $data['actions'] = [ + 'create' => __('create'), + 'update' => __('update'), + 'cancel' => __('cancel'), + 'used' => __('used'), + 'consume' => __('consume'), + 'verify_success' => __('verify_success'), + ]; + } + + if ($isAjax) { + return response()->json([ + 'success' => true, + 'tab' => $tab, + 'html' => view('admin.sales.pass-codes.partials.tab-' . $tab, $data)->render() + ]); + } + + return view('admin.sales.pass-codes.index', $data); } /** @@ -262,6 +355,16 @@ class SalesController extends Controller 'created_by' => Auth::id(), ]); + SystemOperationLog::create([ + 'company_id' => $machine->company_id, + 'user_id' => Auth::id(), + 'module' => 'pass_code', + 'action' => 'create', + 'target_id' => $passCode->id, + 'target_type' => PassCode::class, + 'new_values' => $passCode->toArray(), + ]); + return back()->with('success', __('Pass code created: :code', ['code' => $passCode->code])); } @@ -281,10 +384,23 @@ class SalesController extends Controller $validated['expires_at'] = null; } + $oldValues = $passCode->toArray(); + $passCode->update(array_filter($validated, function ($value, $key) use ($request) { return $request->has($key); }, ARRAY_FILTER_USE_BOTH)); + SystemOperationLog::create([ + 'company_id' => $passCode->company_id, + 'user_id' => Auth::id(), + 'module' => 'pass_code', + 'action' => 'update', + 'target_id' => $passCode->id, + 'target_type' => PassCode::class, + 'old_values' => $oldValues, + 'new_values' => $passCode->toArray(), + ]); + return back()->with('success', __('Pass code updated.')); } @@ -293,7 +409,20 @@ class SalesController extends Controller */ public function destroyPassCode(PassCode $passCode) { + $oldValues = $passCode->toArray(); $passCode->update(['status' => 'disabled']); + + SystemOperationLog::create([ + 'company_id' => $passCode->company_id, + 'user_id' => Auth::id(), + 'module' => 'pass_code', + 'action' => 'cancel', + 'target_id' => $passCode->id, + 'target_type' => PassCode::class, + 'old_values' => $oldValues, + 'new_values' => $passCode->toArray(), + ]); + return back()->with('success', __('Pass code cancelled.')); } diff --git a/app/Http/Controllers/Admin/WarehouseController.php b/app/Http/Controllers/Admin/WarehouseController.php index 858f44c..15b8007 100644 --- a/app/Http/Controllers/Admin/WarehouseController.php +++ b/app/Http/Controllers/Admin/WarehouseController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\Machine\Machine; use App\Models\Machine\MachineSlot; +use App\Models\Machine\MachineStockMovement; use App\Models\Product\Product; use App\Models\Warehouse\ReplenishmentOrder; use App\Models\Warehouse\ReplenishmentOrderItem; @@ -605,6 +606,41 @@ class WarehouseController extends Controller ]); } + /** + * 機台庫存異動紀錄 (AJAX) — 供 Slide-out Panel 使用 + */ + public function machineStockMovements(Request $request, Machine $machine) + { + $query = $machine->stockMovements() + ->with(['product:id,name', 'creator:id,name']) + ->latest(); + + if ($request->filled('type')) { + $query->where('type', $request->input('type')); + } + if ($request->filled('slot_no')) { + $query->where('slot_no', $request->input('slot_no')); + } + if ($request->filled('date_from')) { + $query->whereDate('created_at', '>=', $request->input('date_from')); + } + if ($request->filled('date_to')) { + $query->whereDate('created_at', '<=', $request->input('date_to')); + } + + $movements = $query->paginate($request->input('per_page', 20))->withQueryString(); + + return response()->json([ + 'success' => true, + 'movements' => $movements->items(), + 'meta' => [ + 'current_page' => $movements->currentPage(), + 'last_page' => $movements->lastPage(), + 'total' => $movements->total(), + ], + ]); + } + // ─── 模組 5:機台補貨 ─── public function replenishments(Request $request) @@ -900,12 +936,34 @@ class WarehouseController extends Controller } } - // delivering → completed:增加機台貨道庫存 + // delivering → completed:增加機台貨道庫存並記錄異動 if ($newStatus === ReplenishmentOrder::STATUS_COMPLETED) { foreach ($replenishmentOrder->items as $item) { - MachineSlot::where('machine_id', $replenishmentOrder->machine_id) + $slot = MachineSlot::where('machine_id', $replenishmentOrder->machine_id) ->where('slot_no', $item->slot_no) - ->increment('stock', $item->quantity); + ->lockForUpdate() + ->first(); + + if ($slot) { + $beforeQty = $slot->stock; + $slot->increment('stock', $item->quantity); + $slot->refresh(); + + MachineStockMovement::create([ + 'company_id' => $replenishmentOrder->company_id, + 'machine_id' => $replenishmentOrder->machine_id, + 'product_id' => $item->product_id, + 'slot_no' => $item->slot_no, + 'type' => MachineStockMovement::TYPE_REPLENISHMENT, + 'quantity' => $item->quantity, + 'before_qty' => $beforeQty, + 'after_qty' => $slot->stock, + 'reference_type' => ReplenishmentOrder::class, + 'reference_id' => $replenishmentOrder->id, + 'note' => '補貨單完成入庫 #' . $replenishmentOrder->order_no, + 'created_by' => Auth::id(), + ]); + } } $replenishmentOrder->completed_at = now(); diff --git a/app/Http/Controllers/Api/V1/App/MachineController.php b/app/Http/Controllers/Api/V1/App/MachineController.php index b17521b..f8102a7 100644 --- a/app/Http/Controllers/Api/V1/App/MachineController.php +++ b/app/Http/Controllers/Api/V1/App/MachineController.php @@ -14,7 +14,11 @@ use App\Jobs\Machine\ProcessStateLog; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Cache; +use App\Models\Machine\MachineStockMovement; use App\Models\Transaction\PickupCode; +use App\Models\Transaction\PassCode; +use App\Models\System\SystemOperationLog; +use App\Services\Machine\MachineService; class MachineController extends Controller { @@ -491,80 +495,75 @@ class MachineController extends Controller 'success' => true, 'code' => 200, 'data' => [$data] // App 預期的是包含單一物件的陣列 + ]); } /** - * B660: Verify Pickup Code (with Lockout) - * 驗證 4 位數取貨碼,並具備連續錯誤鎖定機制 + * B660: Verify/Consume Pickup Code + * POST: 驗證 8 位數取貨碼 (與原邏輯一致,但加入日誌) + * PUT: 消耗取貨碼 (扣庫存) */ public function verifyPickupCode(Request $request) { - $machine = $request->get('machine'); // 來自 IotAuth 中間層 - $lockoutKey = "pickup_lockout:{$machine->id}"; - - // 檢查是否處於鎖定狀態 - if (Cache::has($lockoutKey)) { - return response()->json([ - 'success' => false, - 'message' => 'Too many failed attempts. Please try again in 1 minute.', - 'code' => 429 - ], 429); + $machine = $request->get('machine'); + $method = $request->method(); + + if ($method === 'POST') { + return $this->handlePickupVerify($request, $machine); + } elseif ($method === 'PUT') { + return $this->handlePickupConsume($request, $machine); } - $validator = Validator::make($request->all(), [ - 'code' => 'required|string|size:8' - ]); + return response()->json(['success' => false, 'message' => 'Method not allowed'], 405); + } + private function handlePickupVerify(Request $request, $machine) + { + $lockoutKey = "pickup_lockout:{$machine->id}"; + if (Cache::has($lockoutKey)) { + return response()->json(['success' => false, 'message' => 'Too many failed attempts.', 'code' => 429], 429); + } + + $validator = Validator::make($request->all(), ['code' => 'required|string|size:8']); if ($validator->fails()) { - return response()->json([ - 'success' => false, - 'message' => 'Invalid code format', - 'code' => 400 - ], 400); + return response()->json(['success' => false, 'message' => 'Invalid format', 'code' => 400], 400); } $code = $request->input('code'); - $pickupCode = PickupCode::where('machine_id', $machine->id) ->where('code', $code) ->where('status', 'active') - ->where('expires_at', '>', now()) + ->where(function($q) { $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); }) ->first(); if (!$pickupCode) { - // 追蹤失敗次數 - $failKey = "pickup_fails:{$machine->id}"; - $fails = Cache::increment($failKey); - - // 第一次失敗,設定計數器的過期時間 (例如 10 分鐘內累計) - if ($fails === 1) { - Cache::put($failKey, 1, 600); - } - + $fails = Cache::increment("pickup_fails:{$machine->id}"); + if ($fails === 1) Cache::put("pickup_fails:{$machine->id}", 1, 600); if ($fails >= 5) { - Cache::put($lockoutKey, true, 60); // 鎖定 1 分鐘 - Cache::forget($failKey); // 清除錯誤計數 - return response()->json([ - 'success' => false, - 'message' => 'Too many failed attempts. Machine locked for 1 minute.', - 'code' => 429 - ], 429); + Cache::put($lockoutKey, true, 60); + Cache::forget("pickup_fails:{$machine->id}"); } - - return response()->json([ - 'success' => false, - 'message' => 'Invalid code', - 'code' => 404, - 'remaining_attempts' => 5 - $fails - ], 404); + + ProcessStateLog::dispatch($machine->id, $machine->company_id, "[PickupCode] Verification failed: $code", 'warning'); + + return response()->json(['success' => false, 'message' => 'Invalid code', 'code' => 404, 'remaining_attempts' => 5 - $fails], 404); } - // 驗證成功:清除失敗紀錄 Cache::forget("pickup_fails:{$machine->id}"); + // 記錄到系統操作日誌供後台顯示 + SystemOperationLog::create([ + 'company_id' => $machine->company_id, + 'module' => 'pickup_code', + 'action' => 'verify_success', + 'target_id' => $pickupCode->id, + 'target_type' => PickupCode::class, + 'new_values' => ['code' => $pickupCode->code, 'machine_sn' => $machine->serial_no], + 'note' => "log.pickup.verify_success_note", + ]); + return response()->json([ 'success' => true, - 'message' => 'Valid code', 'code' => 200, 'data' => [ 'slot_no' => $pickupCode->slot_no, @@ -573,4 +572,122 @@ class MachineController extends Controller ] ]); } + + private function handlePickupConsume(Request $request, $machine) + { + $pickupCodeId = $request->input('pickup_code_id'); + $status = $request->input('status'); // 1: success, 0: failed + + $pickupCode = PickupCode::where('machine_id', $machine->id) + ->where('id', $pickupCodeId) + ->where('status', 'active') + ->first(); + + if (!$pickupCode) { + return response()->json(['success' => false, 'message' => 'Pickup code not found or already used'], 404); + } + + if ($status == '1') { + // 扣除庫存 + $slot = $machine->slots()->where('slot_no', $pickupCode->slot_no)->first(); + if ($slot && $slot->stock > 0) { + $oldStock = $slot->stock; + $slot->decrement('stock'); + $slot->refresh(); + + // 記錄取貨碼 pickup 庫存異動流水帳 + app(MachineService::class)->recordStockMovement( + $slot, + -1, + MachineStockMovement::TYPE_PICKUP, + $pickupCode, + "取貨碼核銷消耗,碼: {$pickupCode->code}" + ); + + $pickupCode->update(['status' => 'used', 'used_at' => now()]); + + ProcessStateLog::dispatch($machine->id, $machine->company_id, "log.pickup.consume_success", 'info', ['code' => $pickupCode->code, 'slot' => $slot->slot_no]); + + SystemOperationLog::create([ + 'company_id' => $machine->company_id, + 'module' => 'pickup_code', + 'action' => 'consume', + 'target_id' => $pickupCode->id, + 'target_type'=> PickupCode::class, + 'new_values' => [ + 'code' => $pickupCode->code, + 'machine_sn' => $machine->serial_no, + 'slot' => $slot->slot_no, + 'old' => $oldStock, + 'new' => ($oldStock - 1) + ], + 'note' => "log.pickup.consume_success_note", + ]); + } else { + return response()->json(['success' => false, 'message' => 'Out of stock'], 400); + } + } else { + ProcessStateLog::dispatch($machine->id, $machine->company_id, "log.pickup.dispense_failed", 'error', ['code' => $pickupCode->code]); + + SystemOperationLog::create([ + 'company_id' => $machine->company_id, + 'module' => 'pickup_code', + 'action' => 'consume_failed', + 'target_id' => $pickupCode->id, + 'target_type' => PickupCode::class, + 'new_values' => ['code' => $pickupCode->code, 'machine_sn' => $machine->serial_no], + 'note' => "log.pickup.dispense_failed_note", + ]); + } + + return response()->json(['success' => true, 'code' => 200]); + } + + /** + * B670: Verify Pass Code (For Technicians) + */ + public function verifyPassCode(Request $request) + { + $machine = $request->get('machine'); + $lockoutKey = "passcode_lockout:{$machine->id}"; + + if (Cache::has($lockoutKey)) { + return response()->json(['success' => false, 'message' => 'Locked', 'code' => 429], 429); + } + + $validator = Validator::make($request->all(), ['code' => 'required|string|size:8']); + if ($validator->fails()) return response()->json(['success' => false, 'code' => 400], 400); + + $code = $request->input('code'); + $passCode = PassCode::where('machine_id', $machine->id) + ->where('code', $code) + ->where('status', 'active') + ->where(function($q) { $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); }) + ->first(); + + if (!$passCode) { + $fails = Cache::increment("passcode_fails:{$machine->id}"); + if ($fails >= 5) Cache::put($lockoutKey, true, 60); + return response()->json(['success' => false, 'code' => 404], 404); + } + + SystemOperationLog::create([ + 'company_id' => $machine->company_id, + 'module' => 'pass_code', + 'action' => 'verify_success', + 'target_id' => $passCode->id, + 'target_type' => PassCode::class, + 'new_values' => ['code' => $passCode->code, 'machine_sn' => $machine->serial_no], + 'note' => "log.passcode.verify_success_note", + ]); + + return response()->json([ + 'success' => true, + 'code' => 200, + 'data' => [ + 'name' => $passCode->name, + 'status' => 'active' + ] + ]); + } } diff --git a/app/Jobs/Machine/ProcessCommandAck.php b/app/Jobs/Machine/ProcessCommandAck.php index 9f5c8cb..ca351e2 100644 --- a/app/Jobs/Machine/ProcessCommandAck.php +++ b/app/Jobs/Machine/ProcessCommandAck.php @@ -3,7 +3,9 @@ namespace App\Jobs\Machine; use App\Models\Machine\Machine; +use App\Models\Machine\MachineStockMovement; use App\Models\Machine\RemoteCommand; +use App\Services\Machine\MachineService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -34,7 +36,7 @@ class ProcessCommandAck implements ShouldQueue * 處理 B055 出貨回報 ACK * 原始邏輯移植自 MachineController::reportDispenseResult() */ - public function handle(): void + public function handle(MachineService $machineService): void { $commandId = $this->payload['command_id'] ?? null; $resultCode = $this->payload['result'] ?? null; @@ -115,11 +117,30 @@ class ProcessCommandAck implements ShouldQueue $payloadUpdates['rolled_back'] = true; $payloadUpdates['new_stock'] = $oldStock; + // 記錄 rollback 庫存異動流水帳 + $slot->refresh(); + $machineService->recordStockMovement( + $slot, + +1, + MachineStockMovement::TYPE_ROLLBACK, + $command, + "遠端出貨失敗 Rollback,指令 ID: {$command->id},結果碼: {$resultCode}" + ); + Log::warning('MQTT Dispense failed, rolled back inventory', [ - 'serial_no' => $this->serialNo, - 'slot_no' => $slotNo, + 'serial_no' => $this->serialNo, + 'slot_no' => $slotNo, 'rolled_back_to' => $oldStock ]); + + // 記錄到機台日誌 + ProcessStateLog::dispatch( + $machine->id, + $machine->company_id, + "Remote dispense failed for slot :slot", + 'error', + ['slot' => $slotNo] + ); } } } @@ -152,6 +173,20 @@ class ProcessCommandAck implements ShouldQueue 'payload' => array_merge($command->payload, $payloadUpdates), ]); + // 記錄維護類指令到機台日誌 (MachineLog) + if (in_array($command->command_type, ['reboot', 'reboot_card', 'lock', 'unlock', 'checkout', 'change', 'reload_stock'], true)) { + $msgKey = $status === 'success' ? 'log.command.success' : 'log.command.failed'; + $level = $status === 'success' ? 'info' : 'warning'; + + ProcessStateLog::dispatch( + $machine->id, + $machine->company_id, + $msgKey, + $level, + ['type' => __("command.{$command->command_type}")] + ); + } + Log::info("MQTT CommandAck processed", [ 'serial_no' => $this->serialNo, 'command_id' => $commandId, diff --git a/app/Models/Machine/Machine.php b/app/Models/Machine/Machine.php index 3a159a5..42d1eca 100644 --- a/app/Models/Machine/Machine.php +++ b/app/Models/Machine/Machine.php @@ -107,9 +107,10 @@ class Machine extends Model return 'offline'; } - // 判定異常 (檢查過去 15 分鐘內是否有 error 日誌) + // 判定異常 (檢查過去 15 分鐘內是否有 未解決的 error 日誌) $hasRecentErrors = $this->logs() ->where('level', 'error') + ->where('is_resolved', false) ->where('created_at', '>=', now()->subMinutes(15)) ->exists(); @@ -117,6 +118,17 @@ class Machine extends Model return 'error'; } + // 判定警告 (檢查過去 15 分鐘內是否有 未解決的 warning 日誌) + $hasRecentWarnings = $this->logs() + ->where('level', 'warning') + ->where('is_resolved', false) + ->where('created_at', '>=', now()->subMinutes(15)) + ->exists(); + + if ($hasRecentWarnings) { + return 'warning'; + } + return 'online'; } @@ -146,6 +158,7 @@ class Machine extends Model ->from('machine_logs') ->whereColumn('machine_logs.machine_id', 'machines.id') ->whereIn('level', ['error', 'warning']) + ->where('is_resolved', false) ->where('created_at', '>=', now()->subMinutes(15)); }); } @@ -160,6 +173,7 @@ class Machine extends Model ->from('machine_logs') ->whereColumn('machine_logs.machine_id', 'machines.id') ->whereIn('level', ['error', 'warning']) + ->where('is_resolved', false) ->where('created_at', '>=', now()->subMinutes(15)); }); } @@ -279,4 +293,9 @@ class Machine extends Model { return $this->hasMany(\App\Models\Transaction\Order::class); } + + public function stockMovements() + { + return $this->hasMany(MachineStockMovement::class); + } } diff --git a/app/Models/Machine/MachineLog.php b/app/Models/Machine/MachineLog.php index b37a067..f700f39 100644 --- a/app/Models/Machine/MachineLog.php +++ b/app/Models/Machine/MachineLog.php @@ -18,10 +18,12 @@ class MachineLog extends Model 'type', 'message', 'context', + 'is_resolved', ]; protected $casts = [ 'context' => 'array', + 'is_resolved' => 'boolean', ]; protected $appends = [ diff --git a/app/Models/Machine/MachineStockMovement.php b/app/Models/Machine/MachineStockMovement.php new file mode 100644 index 0000000..015abf2 --- /dev/null +++ b/app/Models/Machine/MachineStockMovement.php @@ -0,0 +1,109 @@ +note)) { + return null; + } + + return __($this->note, $this->context ?? []); + } + + protected $casts = [ + 'quantity' => 'integer', + 'before_qty' => 'integer', + 'after_qty' => 'integer', + 'context' => 'array', + ]; + + // ─── 異動類型常數 ─── + + const TYPE_REPLENISHMENT = 'replenishment'; // 補貨單完成 + const TYPE_PICKUP = 'pickup'; // 取貨碼核銷 + const TYPE_REMOTE_DISPENSE = 'remote_dispense'; // 遠端出貨 (B055 樂觀扣除) + const TYPE_ADJUSTMENT = 'adjustment'; // B009 回報 / 後台手動修改 + const TYPE_ROLLBACK = 'rollback'; // B055 出貨失敗回退 + + // ─── 關聯 ─── + + public function machine(): BelongsTo + { + return $this->belongsTo(Machine::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 多型關聯:補貨單、取貨碼操作記錄、遠端指令等 + */ + public function reference(): MorphTo + { + return $this->morphTo(); + } + + // ─── 輔助方法 ─── + + /** + * 是否為增加庫存的類型 + */ + public function isInbound(): bool + { + return in_array($this->type, [ + self::TYPE_REPLENISHMENT, + self::TYPE_ROLLBACK, + ]); + } + + /** + * 是否為減少庫存的類型 + */ + public function isOutbound(): bool + { + return in_array($this->type, [ + self::TYPE_PICKUP, + self::TYPE_REMOTE_DISPENSE, + ]); + } +} diff --git a/app/Models/System/SystemOperationLog.php b/app/Models/System/SystemOperationLog.php index 67781f6..42b1edb 100644 --- a/app/Models/System/SystemOperationLog.php +++ b/app/Models/System/SystemOperationLog.php @@ -10,6 +10,26 @@ use App\Models\System\User; class SystemOperationLog extends Model { use HasFactory, TenantScoped; + + protected $appends = [ + 'translated_note', + ]; + + /** + * 動態翻譯備註 + */ + public function getTranslatedNoteAttribute(): ?string + { + if (empty($this->note)) { + return null; + } + + return __($this->note, array_merge( + $this->old_values ?? [], + $this->new_values ?? [] + )); + } + protected $fillable = [ 'company_id', diff --git a/app/Services/Machine/MachineService.php b/app/Services/Machine/MachineService.php index 0424124..fe0f61f 100644 --- a/app/Services/Machine/MachineService.php +++ b/app/Services/Machine/MachineService.php @@ -4,7 +4,10 @@ namespace App\Services\Machine; use App\Models\Machine\Machine; use App\Models\Machine\MachineLog; +use App\Models\Machine\MachineSlot; +use App\Models\Machine\MachineStockMovement; use App\Models\Machine\RemoteCommand; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Carbon\Carbon; @@ -131,6 +134,42 @@ class MachineService ]); } + /** + * 統一寫入機台庫存異動紀錄 + * + * @param MachineSlot $slot 貨道 Model(已含 machine_id, product_id, stock) + * @param int $delta 異動數量(正=增加,負=減少) + * @param string $type 異動類型(MachineStockMovement::TYPE_*) + * @param mixed $reference 關聯單據 Model(nullable) + * @param string|null $note 備註說明 + */ + public function recordStockMovement( + MachineSlot $slot, + int $delta, + string $type, + mixed $reference = null, + ?string $note = null, + array $context = [] + ): MachineStockMovement { + $before = $slot->stock - $delta; // 因為已更新 slot,反推異動前的值 + + return MachineStockMovement::create([ + 'company_id' => $slot->machine->company_id ?? null, + 'machine_id' => $slot->machine_id, + 'product_id' => $slot->product_id, + 'slot_no' => $slot->slot_no, + 'type' => $type, + 'quantity' => $delta, + 'before_qty' => $before, + 'after_qty' => $slot->stock, + 'reference_type' => $reference ? get_class($reference) : null, + 'reference_id' => $reference?->id, + 'note' => $note, + 'context' => $context, + 'created_by' => Auth::id(), + ]); + } + /** * Sync machine slots based on replenishment report. * @@ -175,17 +214,33 @@ class MachineService } } + $newStock = $slotData['stock'] ?? 0; + $updateData = [ 'product_id' => $actualProductId, 'type' => $slotType, - 'stock' => $slotData['stock'] ?? 0, + 'stock' => $newStock, 'max_stock' => $slotData['capacity'] ?? ($existingSlot->max_stock ?? 10), 'is_active' => true, ]; - // 如果這是一次明確的補貨回報,建議更新時間並記錄 if ($existingSlot) { + // B009 回報:若數值有變化,記錄 adjustment + $oldStock = (int) $existingSlot->stock; $existingSlot->update($updateData); + + if ($oldStock !== $newStock) { + $delta = $newStock - $oldStock; + $existingSlot->refresh(); + $this->recordStockMovement( + $existingSlot, + $delta, + MachineStockMovement::TYPE_ADJUSTMENT, + null, + "movement.note.replenishment_correction", + ['old' => $oldStock, 'new' => $newStock] + ); + } } else { $machine->slots()->create(array_merge($updateData, ['slot_no' => $slotNo])); } @@ -238,6 +293,20 @@ class MachineService if ($stock !== null) $updateData['stock'] = (int)$stock; $slot->update($updateData); + // 3. 若庫存數值有異動,記錄 adjustment 流水帳 + if ($stock !== null && (int)$stock !== $oldData['stock']) { + $delta = (int)$stock - $oldData['stock']; + $slot->refresh(); + $this->recordStockMovement( + $slot, + $delta, + MachineStockMovement::TYPE_ADJUSTMENT, + null, + "movement.note.manual_adjustment", + ['old' => $oldData['stock'], 'new' => $stock] + ); + } + // 3. 建立遠端指令紀錄 $command = RemoteCommand::create([ 'machine_id' => $machine->id, @@ -302,7 +371,7 @@ class MachineService $oldStock = $slot->stock; $newStock = $oldStock - 1; - // 1. 執行樂觀扣薪 + // 1. 執行樂觀扣除 $slot->update(['stock' => $newStock]); // 2. 建立指令紀錄 @@ -318,7 +387,17 @@ class MachineService ] ]); - // 3. 推播 MQTT + // 3. 寫入庫存異動流水帳(remote_dispense,樂觀扣除) + $slot->refresh(); + $this->recordStockMovement( + $slot, + -1, + MachineStockMovement::TYPE_REMOTE_DISPENSE, + $command, + "遠端出貨指令 (B055),指令 ID: {$command->id}" + ); + + // 4. 推播 MQTT app(\App\Services\Machine\MqttService::class)->pushCommand( $machine->serial_no, 'dispense', diff --git a/compose.yaml b/compose.yaml index 2c881fb..276c232 100644 --- a/compose.yaml +++ b/compose.yaml @@ -28,6 +28,7 @@ services: depends_on: - mysql - redis + restart: unless-stopped laravel.queue: image: 'sail-8.5/app' @@ -45,7 +46,7 @@ services: depends_on: - mysql - redis - restart: always + restart: unless-stopped laravel.mqtt: image: 'sail-8.5/app' container_name: star-cloud-mqtt-worker @@ -63,7 +64,7 @@ services: - mysql - redis - emqx - restart: always + restart: unless-stopped mysql: image: 'mysql/mysql-server:8.0' @@ -92,6 +93,7 @@ services: - '-p${DB_PASSWORD}' retries: 3 timeout: 5s + restart: unless-stopped redis: image: 'redis:alpine' container_name: star-cloud-redis @@ -109,6 +111,7 @@ services: - ping retries: 3 timeout: 5s + restart: unless-stopped emqx: image: 'emqx/emqx:5.10.3' container_name: star-cloud-emqx @@ -134,6 +137,7 @@ services: retries: 5 depends_on: - redis + restart: unless-stopped mqtt-gateway: build: context: ./mqtt-gateway @@ -153,7 +157,7 @@ services: TZ: 'Asia/Taipei' networks: - sail - restart: always + restart: unless-stopped depends_on: - emqx - redis diff --git a/config/api-docs.php b/config/api-docs.php index e3bc1f9..c49b7bf 100644 --- a/config/api-docs.php +++ b/config/api-docs.php @@ -275,86 +275,120 @@ return [ ] ], 'request' => [], - 'response' => [ - 'success' => true, - 'code' => 200, - 'message' => 'OK', - 'data' => [ - [ - 't060v00' => '1', - 't060v01' => '可口可樂 330ml', - 't060v01_en' => 'Coca Cola', - 't060v01_jp' => 'コカコーラ', - 't060v03' => 'Cold Drink', - 't060v06' => 'https://.../coke.png', - 't060v09' => 25.0, - 't060v11' => 10, - 't060v30' => 20.0, - 't063v03' => 25.0, - 't060v40' => 'Buy 1 Get 1', - 't060v41' => 'SKU-001', - 'spring_limit' => 10, - 'track_limit' => 15 - ] - ] - ], - 'notes' => '運作邏輯 (Client-side Logic): GET 執行全量同步,App 應於收到成功回應後,先執行 deleteAll() 再進行 insertAll()。PATCH 執行增量更新,App 僅對記憶體中的既存商品進行欄位值覆蓋 (Patching)。' ], [ - 'name' => 'B017: 貨道庫存同步 (Slot Synchronization)', - 'slug' => 'b017-slot-sync', - 'method' => 'GET', - 'path' => '/api/v1/app/machine/reload_msg/B017', - 'description' => '用於機台端獲獲取所有貨道的最新庫存、效期與狀態。通常由 B010 回傳 status: 49 時觸發。', + 'name' => 'B650: 會員身分驗證 (Member Verification)', + 'slug' => 'b650-member-verify', + 'method' => 'POST', + 'path' => '/api/v1/app/machine/member/verify/B650', + 'description' => '機台端掃碼後驗證會員身分。', 'headers' => [ 'Authorization' => 'Bearer ', 'Content-Type' => 'application/json', ], - 'parameters' => [], - 'response_parameters' => [ - 'success' => [ - 'type' => 'boolean', - 'description' => '是否成功', - 'example' => true - ], - 'data' => [ - 'type' => 'array', - 'description' => '貨道數據陣列。', - 'example' => [ - [ - 'tid' => '1', - 'num' => 10, - 'expiry_date' => '2026-12-31', - 'batch_no' => 'B2026', - 'status' => '1' - ] - ] - ], - ], - 'request' => [], - 'response' => [ - 'success' => true, - 'code' => 200, - 'data' => [ - [ - 'tid' => '1', - 'num' => 10, - 'expiry_date' => '2026-12-31', - 'batch_no' => 'B2026', - 'product_id' => 1, - 'capacity' => 15, - 'status' => '1' - ] + 'parameters' => [ + 'code' => [ + 'type' => 'string', + 'required' => true, + 'description' => '會員代碼', + 'example' => 'MEMBER12345' + ] + ], + 'response' => [ + 'success' => true, + 'data' => [ + 'member_id' => 123, + 'name' => '王小明', + 'points' => 500 ] ], - 'notes' => 'B017 為全量同步。實作上後端會依據 slot_no 進行排序,並將相關指令狀態更新為已完成。' ], [ - 'name' => 'B024: 取貨碼/通行碼驗證與消耗回報', - 'slug' => 'b024-access-code', + 'name' => 'B660: 取貨碼驗證與消耗 (Pickup Code)', + 'slug' => 'b660-pickup-code', + 'method' => 'POST / PUT', + 'path' => '/api/v1/app/machine/pickup/verify/B660', + 'description' => '處理兩階段代碼取貨。POST 驗證有效性,PUT 回報出貨成功後正式扣庫存。', + 'headers' => [ + 'Authorization' => 'Bearer ', + 'Content-Type' => 'application/json', + ], + 'parameters' => [ + 'code' => [ + 'type' => 'string', + 'description' => '8 位數取貨碼 (僅限 POST)', + 'example' => '12345678' + ], + 'pickup_code_id' => [ + 'type' => 'integer', + 'description' => '代碼內部 ID (僅限 PUT)', + 'example' => 45 + ], + 'status' => [ + 'type' => 'integer', + 'description' => '出貨狀態 (僅限 PUT: 1:成功, 0:失敗)', + 'example' => 1 + ], + ], + 'response' => [ + 'success' => true, + 'data' => [ + 'slot_no' => '1', + 'pickup_code_id' => 45, + 'status' => 'active' + ] + ], + 'notes' => '安全性:連續錯誤 5 次將鎖定該機台驗證功能 1 分鐘。' + ], + [ + 'name' => 'B670: 通行碼驗證 (Pass Code)', + 'slug' => 'b670-pass-code', 'method' => 'POST', - 'path' => '/api/v1/app/sell/access-code/B024', - 'description' => '處理代碼取貨流程。POST 用於驗證碼有效性,PUT 用於回報出貨成功並消耗代碼。', + 'path' => '/api/v1/app/machine/passcode/verify/B670', + 'description' => '專供維修/工程人員使用的通行碼驗證。僅做身份確認,不涉及商品消耗。', + 'headers' => [ + 'Authorization' => 'Bearer ', + 'Content-Type' => 'application/json', + ], + 'parameters' => [ + 'code' => [ + 'type' => 'string', + 'required' => true, + 'description' => '8 位數通行碼', + 'example' => '88888888' + ] + ], + 'response' => [ + 'success' => true, + 'data' => [ + 'name' => '工程師 A', + 'status' => 'active' + ] + ], + 'notes' => '僅供現場測試使用,錯誤過多同樣會觸發鎖定。' + ], + [ + 'name' => 'B710: 機台時間同步 (Timer Sync)', + 'slug' => 'b710-timer-sync', + 'method' => 'POST', + 'path' => '/api/v1/app/machine/timer/B710', + 'description' => '校準機台 RTC 時間。', + 'headers' => [ + 'Authorization' => 'Bearer ', + ], + 'response' => [ + 'success' => true, + 'data' => [ + 'timestamp' => 1714444800 + ] + ] + ], + [ + 'name' => 'B027: 贈品碼/優惠券驗證與消耗 (Free Gift Verify & Consume)', + 'slug' => 'b027-freebie-code', + 'method' => 'POST / PUT', + 'path' => '/api/v1/app/sell/free-gift/B027', + 'description' => 'POST 用於驗證碼有效性,PUT 用於回報出貨成功並消耗代碼。', 'headers' => [ 'Authorization' => 'Bearer ', 'Content-Type' => 'application/json', @@ -362,7 +396,7 @@ return [ 'parameters' => [ 'passCode' => [ 'type' => 'string', - 'description' => '取貨碼 (POST)', + 'description' => '贈品碼 (POST)', ], 'accessCodeId' => [ 'type' => 'string', @@ -373,37 +407,9 @@ return [ 'description' => '出貨狀態 (PUT: 1:成功, 0:失敗)', ], ], - 'response_parameters' => [ - 'res1' => ['type' => 'string', 'description' => '雲端關聯 ID'], - 'res3' => ['type' => 'string', 'description' => '預計出貨商品 ID'], - ], - 'request' => [ - 'passCode' => '12345678' - ], - 'response' => [ - 'success' => true, - 'res1' => '99', - 'res3' => '5' - ], - ], - [ - 'name' => 'B027: 贈品碼/優惠券驗證與消耗回報', - 'slug' => 'b027-freebie-code', - 'method' => 'POST', - 'path' => '/api/v1/app/sell/free-gift/B027', - 'description' => '處理贈品券與 0 元購活動。邏輯與 B024 相似但對象為行銷贈品。', - 'headers' => [ - 'Authorization' => 'Bearer ', - 'Content-Type' => 'application/json', - ], - 'parameters' => [ - 'passCode' => [ - 'type' => 'string', - 'description' => '贈品碼 (POST)', - ], - ], 'response_parameters' => [ 'success' => ['type' => 'boolean', 'description' => '驗證結果'], + 'res1' => ['type' => 'string', 'description' => '雲端關聯 ID'], ], 'request' => [ 'passCode' => 'FREE888' @@ -413,8 +419,8 @@ return [ 'message' => 'Free gift verified' ], ], - ] - ] + ], + ], ], 'mqtt_topics' => [ diff --git a/database/migrations/2026_04_30_150247_create_machine_stock_movements_table.php b/database/migrations/2026_04_30_150247_create_machine_stock_movements_table.php new file mode 100644 index 0000000..6d73041 --- /dev/null +++ b/database/migrations/2026_04_30_150247_create_machine_stock_movements_table.php @@ -0,0 +1,56 @@ +id(); + $table->unsignedBigInteger('company_id')->nullable()->index(); + $table->unsignedBigInteger('machine_id')->index(); + $table->unsignedBigInteger('product_id')->nullable()->index(); + $table->string('slot_no', 20); + + // 異動類型:補貨入庫、取貨碼消耗、遠端出貨、盤點調整、出貨失敗回退 + $table->enum('type', [ + 'replenishment', // 補貨單完成 + 'pickup', // 取貨碼核銷 + 'remote_dispense', // 遠端出貨指令 (B055 樂觀扣除) + 'adjustment', // B009 盤點回報 / 後台手動修改 + 'rollback', // B055 出貨失敗,庫存回退 + ]); + + $table->integer('quantity'); // 異動數量(正數=增加,負數=減少) + $table->integer('before_qty'); // 異動前數量 + $table->integer('after_qty'); // 異動後數量 + + // 多型關聯(可關聯補貨單、取貨碼、遠端指令等) + $table->string('reference_type')->nullable(); + $table->unsignedBigInteger('reference_id')->nullable(); + $table->index(['reference_type', 'reference_id']); + + $table->text('note')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + + $table->timestamps(); + + // 複合索引:查詢特定機台某時段的異動 + $table->index(['machine_id', 'slot_no', 'created_at']); + // 多租戶過濾 + 類型篩選 + $table->index(['company_id', 'type', 'created_at']); + + $table->foreign('machine_id')->references('id')->on('machines')->cascadeOnDelete(); + $table->foreign('product_id')->references('id')->on('products')->nullOnDelete(); + $table->foreign('created_by')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('machine_stock_movements'); + } +}; diff --git a/database/migrations/2026_04_30_164641_add_context_to_machine_stock_movements_table.php b/database/migrations/2026_04_30_164641_add_context_to_machine_stock_movements_table.php new file mode 100644 index 0000000..35d3d74 --- /dev/null +++ b/database/migrations/2026_04_30_164641_add_context_to_machine_stock_movements_table.php @@ -0,0 +1,28 @@ +json('context')->nullable()->after('note'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('machine_stock_movements', function (Blueprint $table) { + $table->dropColumn('context'); + }); + } +}; diff --git a/database/migrations/2026_04_30_172006_add_is_resolved_to_machine_logs_table.php b/database/migrations/2026_04_30_172006_add_is_resolved_to_machine_logs_table.php new file mode 100644 index 0000000..273815d --- /dev/null +++ b/database/migrations/2026_04_30_172006_add_is_resolved_to_machine_logs_table.php @@ -0,0 +1,28 @@ +boolean('is_resolved')->default(false)->after('message'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('machine_logs', function (Blueprint $table) { + $table->dropColumn('is_resolved'); + }); + } +}; diff --git a/lang/en.json b/lang/en.json index f9f7cae..3d8ef6c 100644 --- a/lang/en.json +++ b/lang/en.json @@ -28,6 +28,7 @@ "Account:": "Account:", "Accounts / Machines": "Accounts / Machines", "Action": "Action", + "Action / Target": "Action / Target", "Actions": "Actions", "Active": "Active", "Active Slots": "Active Slots", @@ -115,6 +116,7 @@ "Are you sure you want to confirm this stock-in order? This action will update your inventory levels.": "Are you sure you want to confirm this stock-in order? This action will update your inventory levels.", "Are you sure you want to confirm this transfer? This will deduct stock from source and add to target.": "Are you sure you want to confirm this transfer? This will deduct stock from source and add to target.", "Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.": "Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.", + "Are you sure you want to deactivate this pass code? It will no longer be usable for authentication.": "Are you sure you want to deactivate this pass code? It will no longer be usable for authentication.", "Are you sure you want to delete this account?": "Are you sure you want to delete this account?", "Are you sure you want to delete this account? This action cannot be undone.": "Are you sure you want to delete this account? This action cannot be undone.", "Are you sure you want to delete this advertisement? This will also remove all assignments to machines.": "Are you sure you want to delete this advertisement? This will also remove all assignments to machines.", @@ -259,7 +261,8 @@ "Confirm Cancel": "Confirm Cancel", "Confirm Changes": "Confirm Changes", "Confirm Complete": "Confirm Complete", - "Confirm Deletion": "Confirm Deletion", + "Confirm Deactivation": "Confirm Deactivation", + "Confirm Delete": "Confirm Delete", "Confirm Password": "Confirm Password", "Confirm Prepare": "Confirm Prepare", "Confirm Status Change": "Confirm Status Change", @@ -619,6 +622,7 @@ "Loading Cabinet...": "Loading Cabinet...", "Loading Data": "Loading Data", "Loading machines...": "Loading machines...", + "Loading failed": "Loading failed", "Loading...": "Loading...", "Location": "Location", "Location found!": "Location found!", @@ -767,6 +771,17 @@ "Motor not stopped": "Motor not stopped", "Movement History": "Movement History", "Movement Logs": "Movement Logs", + "Machine Stock Movements": "Machine Stock Movements", + "Stock Movement Log": "Stock Movement Log", + "movement.type.replenishment": "Replenishment", + "movement.type.pickup": "Pickup Code Consumed", + "movement.type.remote_dispense": "Remote Dispense", + "movement.type.adjustment": "Stock Adjustment", + "movement.type.rollback": "Dispense Rollback", + "Before Qty": "Before Qty", + "After Qty": "After Qty", + "Delta": "Delta", + "No movements found": "No movements found", "Multilingual Names": "Multilingual Names", "N/A": "N/A", "Name": "Name", @@ -1158,7 +1173,8 @@ "Schedule": "Schedule", "Search Company Title...": "Search Company Title...", "Search Machine...": "Search Machine...", - "Search Product": "Search Product", + "Search logs...": "Search logs...", + "Searchable Select": "Searchable Select", "Search accounts...": "Search accounts...", "Search by code, machine name or serial...": "Search by code, machine name or serial...", "Search by code, name or machine...": "Search by code, name or machine...", @@ -1444,7 +1460,13 @@ "Unlock Now": "Unlock Now", "Unlock Page": "Unlock Page", "Unpublish": "Unpublish", + "cancel": "Cancel", + "create": "Create", + "update": "Update", "Update": "Update", + "Edit Pass Code": "Edit Pass Code", + "Permanent if empty": "Permanent if empty", + "Update Pass Code": "Update Pass Code", "Update Authorization": "Update Authorization", "Update Customer": "Update Customer", "Update Password": "Update Password", @@ -1528,6 +1550,7 @@ "Welcome Gift Status": "Welcome Gift Status", "Work Content": "Work Content", "Yes, Cancel": "Yes, Cancel", + "Yes, Deactivate": "Yes, Deactivate", "Yes, Delete": "Yes, Delete", "Yes, Disable": "Yes, Disable", "Yes, regenerate": "Yes, regenerate", @@ -1647,5 +1670,9 @@ "visit_gift": "visit_gift", "vs Yesterday": "vs Yesterday", "warehouses": "warehouses", - "待填寫": "待填寫" + "used": "Used", + "待填寫": "待填寫", + "verify_success": "Verification Success", + "consume": "Pickup Success", + "consume_failed": "Pickup Failed" } diff --git a/lang/ja.json b/lang/ja.json index 60d7a96..fa17280 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -27,6 +27,8 @@ "Account updated successfully.": "Account updated successfully.", "Account:": "Account:", "Accounts / Machines": "Accounts / Machines", + "Action": "操作タイプ", + "Action / Target": "操作 / 対象", "Actions": "操作", "Active": "有効", "Active Slots": "稼働中の貨道", @@ -114,7 +116,8 @@ "Are you sure you want to confirm this stock-in order? This action will update your inventory levels.": "この入庫伝票を確定してもよろしいですか?この操作により在庫レベルが更新されます。", "Are you sure you want to confirm this transfer? This will deduct stock from source and add to target.": "この転送を確定してもよろしいですか?この操作により発送元の在庫が減算され、対象の在庫に加算されます。", "Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.": "Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.", - "Are you sure you want to delete this account?": "Are you sure you want to delete this account?", + "Are you sure you want to deactivate this pass code? It will no longer be usable for authentication.": "この通行コードを無効にしてもよろしいですか?無効にすると認証に使用できなくなります。", + "Are you sure you want to delete this account?": "このアカウントを削除してもよろしいですか?", "Are you sure you want to delete this account? This action cannot be undone.": "Are you sure you want to delete this account? This action cannot be undone.", "Are you sure you want to delete this advertisement? This will also remove all assignments to machines.": "Are you sure you want to delete this advertisement? This will also remove all assignments to machines.", "Are you sure you want to delete this configuration?": "Are you sure you want to delete this configuration?", @@ -256,7 +259,8 @@ "Confirm Account Status Change": "Confirm Account Status Change", "Confirm Assignment": "Confirm Assignment", "Confirm Cancel": "キャンセル確認", - "Confirm Changes": "Confirm Changes", + "Confirm Deactivation": "無効化の確認", + "Confirm Delete": "削除の確認", "Confirm Complete": "完了を確認", "Confirm Deletion": "削除の確認", "Confirm Password": "Confirm Password", @@ -352,7 +356,8 @@ "Default Not Donate": "Default Not Donate", "Define and manage security roles and permissions.": "Define and manage security roles and permissions.", "Define new third-party payment parameters": "Define new third-party payment parameters", - "Delete": "Delete", + "Yes, Deactivate": "はい、無効にします", + "Yes, Delete": "はい、削除します", "Delete Account": "Delete Account", "Delete Advertisement": "Delete Advertisement", "Delete Advertisement Confirmation": "Delete Advertisement Confirmation", @@ -617,7 +622,8 @@ "Live Fleet Updates": "Live Fleet Updates", "Loading Cabinet...": "Loading Cabinet...", "Loading Data": "Loading Data", - "Loading machines...": "Loading machines...", + "Loading machines...": "マシンの読み込み中...", + "Loading failed": "読み込みに失敗しました", "Loading...": "読み込み中...", "Location": "Location", "Location found!": "Location found!", @@ -763,6 +769,17 @@ "Motor not stopped": "Motor not stopped", "Movement History": "移動履歴", "Movement Logs": "移動ログ", + "Machine Stock Movements": "機台在庫異動履歴", + "Stock Movement Log": "在庫異動ログ", + "movement.type.replenishment": "補充入庫", + "movement.type.pickup": "受取コード消費", + "movement.type.remote_dispense": "リモート出庫", + "movement.type.adjustment": "棚卸調整", + "movement.type.rollback": "出庫失敗ロールバック", + "Before Qty": "変動前数量", + "After Qty": "変動後数量", + "Delta": "変動量", + "No movements found": "異動記録なし", "Multilingual Names": "Multilingual Names", "N/A": "N/A", "Name": "Name", @@ -1045,7 +1062,8 @@ "Quick Expiry Check": "Quick Expiry Check", "Quick Maintenance": "Quick Maintenance", "Quick Replenish": "クイック補充", - "Quick Select": "Quick Select", + "Search logs...": "ログを検索...", + "Searchable Select": "Searchable Select", "Quick replenishment from this view": "Quick replenishment from this view", "Quick search...": "Quick search...", "Real-time OEE analysis awaits": "Real-time OEE analysis awaits", @@ -1439,8 +1457,11 @@ "Unlock Now": "Unlock Now", "Unlock Page": "Unlock Page", "Unpublish": "配信終了", - "Update": "Update", - "Update Authorization": "Update Authorization", + "Update": "更新", + "Edit Pass Code": "通行コード編集", + "Permanent if empty": "空欄の場合は永久", + "Update Pass Code": "通行コード更新", + "Update Authorization": "承認の更新", "Update Customer": "Update Customer", "Update Password": "Update Password", "Update Product": "Update Product", @@ -1523,7 +1544,6 @@ "Welcome Gift Status": "Welcome Gift Status", "Work Content": "作業内容", "Yes, Cancel": "はい、キャンセルします", - "Yes, Delete": "はい、無効にします", "Yes, Disable": "はい、無効にします", "Yesterday": "Yesterday", "You can assign or change the personnel handling this order": "この注文の担当者を割り当て、または変更できます", @@ -1640,5 +1660,8 @@ "visit_gift": "visit_gift", "vs Yesterday": "vs Yesterday", "warehouses": "warehouses", - "待填寫": "待填寫" + "待填寫": "待填寫", + "verify_success": "検証成功", + "consume": "受取成功", + "consume_failed": "受取失敗" } diff --git a/lang/zh_TW.json b/lang/zh_TW.json index e54c601..9c8d2ae 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -27,6 +27,8 @@ "Account updated successfully.": "帳號已成功更新。", "Account:": "帳號:", "Accounts / Machines": "帳號 / 機台", + "Action": "操作", + "Action / Target": "操作 / 對象", "Actions": "操作", "Active": "有效", "Active Slots": "使用中貨道", @@ -114,6 +116,7 @@ "Are you sure you want to confirm this stock-in order? This action will update your inventory levels.": "您確定要確認此進貨單嗎?此操作將會更新您的庫存數量。", "Are you sure you want to confirm this transfer? This will deduct stock from source and add to target.": "您確定要確認此調撥單嗎?此操作將會扣除來源庫存並增加目標庫存。", "Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.": "確定要停用此帳號嗎?停用後將無法登入系統。", + "Are you sure you want to deactivate this pass code? It will no longer be usable for authentication.": "您確定要停用此通行碼嗎?停用後將無法再用於驗證。", "Are you sure you want to delete this account?": "您確定要刪除此帳號嗎?", "Are you sure you want to delete this account? This action cannot be undone.": "確定要刪除此帳號嗎?此操作無法復原。", "Are you sure you want to delete this advertisement? This will also remove all assignments to machines.": "您確定要刪除此廣告嗎?這也將移除所有對機台的投放。", @@ -257,6 +260,8 @@ "Confirm Cancel": "確認取消", "Confirm Changes": "確認變更", "Confirm Complete": "確認完成", + "Confirm Deactivation": "確認停用", + "Confirm Delete": "確認刪除", "Confirm Deletion": "確認刪除資料", "Confirm Password": "確認新密碼", "Confirm Prepare": "確認備貨", @@ -298,7 +303,7 @@ "Create Config": "建立配置", "Create Machine": "新增機台", "Create New Role": "建立新角色", - "Create Payment Config": "新增金流配置", + "Create Payment Config": "建立金流配置", "Create Product": "建立商品", "Create Replenishment Order": "建立補貨單", "Create Role": "建立角色", @@ -615,6 +620,7 @@ "Loading Cabinet...": "正在載入機台貨盤...", "Loading Data": "載入資料中", "Loading machines...": "正在載入機台...", + "Loading failed": "載入失敗", "Loading...": "載入中...", "Location": "位置", "Location found!": "已成功獲取座標!", @@ -761,6 +767,17 @@ "Motor not stopped": "電機未停止", "Movement History": "異動紀錄", "Movement Logs": "異動紀錄", + "Machine Stock Movements": "機台庫存流水帳", + "Stock Movement Log": "庫存流水帳", + "movement.type.replenishment": "補貨入庫", + "movement.type.pickup": "取貨碼消耗", + "movement.type.remote_dispense": "遠端出貨", + "movement.type.adjustment": "盤點調整", + "movement.type.rollback": "出貨失敗退回", + "Before Qty": "異動前數量", + "After Qty": "異動後數量", + "Delta": "變化量", + "No movements found": "尚無異動紀錄", "Multilingual Names": "多語系名稱", "N/A": "不適用", "Name": "名稱", @@ -1073,6 +1090,7 @@ "Remote Reboot": "遠端結帳", "Remote Settlement": "遠端結帳", "Remote dispense successful for slot :slot": "貨道 :slot 遠端出貨成功", + "Remote dispense failed for slot :slot": "貨道 :slot 遠端出貨失敗", "Removal": "撤機", "Repair": "維修", "Replenish": "補貨", @@ -1151,6 +1169,7 @@ "Schedule": "排程區間", "Search Company Title...": "搜尋公司名稱...", "Search Machine...": "搜尋機台...", + "Search logs...": "搜尋紀錄...", "Search Product": "搜尋商品", "Search accounts...": "搜尋帳號...", "Search by code, machine name or serial...": "搜尋代碼、機台名稱或序號...", @@ -1279,7 +1298,7 @@ "Stock & Expiry Overview": "庫存與效期一覽", "Stock / Capacity": "庫存/上限", "Stock In": "庫存入庫", - "Stock Level": "庫存量", + "Stock Level": "庫存水準", "Stock Level > 50%": "庫存高於 50%", "Stock Levels": "庫存水準", "Stock Management": "庫存管理單", @@ -1436,7 +1455,14 @@ "Unlock Now": "立即解鎖", "Unlock Page": "頁面解鎖", "Unpublish": "下架", + "cancel": "取消", + "create": "建立", + "update": "更新", + "used": "已使用", "Update": "更新", + "Edit Pass Code": "編輯通行碼", + "Permanent if empty": "留空則為永久有效", + "Update Pass Code": "更新通行碼", "Update Authorization": "更新授權", "Update Customer": "更新客戶", "Update Password": "更改密碼", @@ -1520,13 +1546,13 @@ "Welcome Gift Status": "來店禮", "Work Content": "作業內容", "Yes, Cancel": "確認取消", - "Yes, Delete": "是的,停用", + "Yes, Deactivate": "是的,停用", + "Yes, Delete": "是的,刪除", "Yes, Disable": "是的,停用", "Yesterday": "昨日", "You can assign or change the personnel handling this order": "您可以指派或變更處理此訂單的人員", "You cannot assign permissions you do not possess.": "您無法指派您自身不具備的權限。", "You cannot delete your own account.": "您無法刪除自己的帳號。", - "Your email address is unverified.": "您的電子郵件地址尚未驗證。", "Your recent account activity": "最近的帳號活動", "accounts": "帳號管理", "admin": "管理員", @@ -1637,5 +1663,32 @@ "visit_gift": "來店禮", "vs Yesterday": "較昨日", "warehouses": "倉庫管理", - "待填寫": "待填寫" -} \ No newline at end of file + "待填寫": "待填寫", + "verify_success": "驗證成功", + "consume": "取貨成功", + "consume_failed": "取貨失敗", + "All Slots": "所有貨道", + "log.pickup.verify_success": "[取貨碼] 驗證成功,碼::code", + "log.pickup.verify_success_note": "機台 [:machine_sn] 已驗證取貨碼 [:code]", + "log.pickup.consume_success": "[取貨碼] 核銷成功::code,貨道::slot", + "log.pickup.consume_success_note": "機台 [:machine_sn] 已核銷取貨碼。貨道 :slot::old -> :new", + "log.pickup.dispense_failed": "[取貨碼] 機台回報出貨失敗::code", + "log.pickup.dispense_failed_note": "機台 [:machine_sn] 回報取貨碼 [:code] 出貨失敗", + "log.passcode.verify_success_note": "維修人員於機台 [:machine_sn] 驗證通行碼 [:code]", + "movement.note.manual_adjustment": "後台手動修改貨道庫存(舊: :old → 新: :new)", + "movement.note.replenishment_correction": "B009 盤點回報修正(舊: :old → 新: :new)", + "log.command.success": "遠端指令 [:type] 執行成功", + "log.command.failed": "遠端指令 [:type] 執行失敗", + "command.reboot": "重啟", + "command.reboot_card": "讀卡機重啟", + "command.lock": "鎖機", + "command.unlock": "解鎖", + "command.checkout": "結帳", + "command.change": "找零", + "command.dispense": "出貨", + "command.reload_stock": "同步庫存", + "Clear Abnormal Status": "清除異常狀態", + "Are you sure you want to resolve all recent issues for this machine?": "您確定要排除此機台目前所有的異常紀錄嗎?這將會恢復機台狀態為正常。", + "All issues marked as resolved.": "所有異常已標記為已排除。", + "Failed to resolve issues.": "排除異常失敗。" +} diff --git a/resources/css/app.css b/resources/css/app.css index e2f4876..50017be 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -324,11 +324,11 @@ /* Small variants for filter bars */ .luxury-input-sm { - @apply py-2.5 text-sm !important; + @apply py-2 text-sm !important; } .luxury-select-sm .hs-select-toggle { - @apply py-2.5 text-sm !important; + @apply py-2 text-sm !important; } } /* Flatpickr Luxury Theme Overrides */ diff --git a/resources/views/admin/machines/index.blade.php b/resources/views/admin/machines/index.blade.php index fea45ff..16646a3 100644 --- a/resources/views/admin/machines/index.blade.php +++ b/resources/views/admin/machines/index.blade.php @@ -8,6 +8,7 @@ showLogPanel: false, showEditModal: false, showInventoryPanel: false, + showResolveConfirm: false, editMachineId: '', editMachineName: '', activeTab: 'status', @@ -131,6 +132,35 @@ finally { this.inventoryLoading = false; } }, + confirmResolve() { + this.showResolveConfirm = true; + }, + + async executeResolve() { + this.showResolveConfirm = false; + try { + const res = await fetch(`/admin/machines/${this.currentMachineId}/resolve-logs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': '{{ csrf_token() }}' + } + }); + const data = await res.json(); + if (data.success) { + window.showToast(data.message || "{{ __('All issues marked as resolved.') }}", 'success'); + this.fetchLogs(1); + // Refresh the machine list background to update the status bulb + this.fetchPage(window.location.href); + } else { + window.showToast(data.message || "{{ __('Failed to resolve issues.') }}", 'error'); + } + } catch (e) { + console.error('resolveLogs error:', e); + } + }, + formatDateTime(dateStr) { if (!dateStr) return '--'; const d = new Date(dateStr); @@ -269,6 +299,14 @@ class="text-xs font-black text-emerald-600 dark:text-emerald-400 tracking-widest uppercase">{{ __('Online') }} + @elseif($cStatus === 'warning') +
+
+ {{ + __('Warning') }} +
@elseif($cStatus === 'error')
@@ -398,6 +436,8 @@ class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75">
+ @elseif($cStatus === 'warning') +
@elseif($cStatus === 'error')
@else @@ -502,7 +542,7 @@ x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full" class="w-screen max-w-4xl"> -
+
@@ -593,7 +633,14 @@
-
+
+ -
- - / - -
- - -
-
+ + +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ {{ __('items') }} +
+
@@ -898,7 +947,7 @@ x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full" class="w-screen max-w-4xl"> -
+
@@ -1118,4 +1167,15 @@
+ + @endsection \ No newline at end of file diff --git a/resources/views/admin/sales/pass-codes/index.blade.php b/resources/views/admin/sales/pass-codes/index.blade.php index 8a2bea1..ee47fef 100644 --- a/resources/views/admin/sales/pass-codes/index.blade.php +++ b/resources/views/admin/sales/pass-codes/index.blade.php @@ -1,7 +1,9 @@ @extends('layouts.admin') @section('content') -
{ + const url = new URL(window.location.origin + window.location.pathname); + url.searchParams.set('tab', newTab); + window.history.pushState({}, '', url); + this.$nextTick(() => { + if (window.HSStaticMethods) window.HSStaticMethods.autoInit(); + }); + }); + this.$watch('showCreateModal', (val) => { if (val) { this.selectedMachine = ''; @@ -153,7 +199,7 @@ } }); } -} "> +} " @ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)"> {{-- Page Header --}} @@ -165,310 +211,39 @@ - {{-- Main Content Card --}} -
- + {{-- Tabs --}} + + + + -
- -
- -
- - - - - - - + {{-- Main Content --}} +
+
+ {{-- List Tab --}} +
+
+ +
+ @include('admin.sales.pass-codes.partials.tab-list') +
- -
- - - - - -
- -
- - -
- - - {{-- Table (Desktop) --}} - - {{-- Card Grid (Mobile) --}} -
- @forelse ($passCodes as $code) -
- {{-- Card Header --}} -
-
-
- - - -
-
- @if($code->status === 'active' && (!$code->expires_at || $code->expires_at->isFuture())) -

- {{ $code->code }} -

- @else -

- {{ $code->code }} -

- @endif -

- {{ $code->name }} -

-
-
-
- @php - $displayStatusMobile = $code->status === 'active' && $code->expires_at?->isPast() ? - 'expired' : ($code->status === 'disabled' ? 'cancelled' : $code->status); - @endphp - -
-
- - {{-- Info Grid --}} -
-
-

- {{ __('Machine') }}

-

{{ - $code->machine->name }}

-
-
-

- {{ __('Expires At') }}

-

- {{ $code->expires_at ? $code->expires_at->format('Y-m-d H:i') : __('Permanent') }} -

-
-
- - {{-- Action Buttons --}} -
- @if($code->status === 'active' && (!$code->expires_at || $code->expires_at->isFuture())) - - - @endif - - @if($code->status === 'active') -
- @csrf - @method('DELETE') - -
- @endif + {{-- Logs Tab --}} + - - {{-- Pagination --}} -
- {{ $passCodes->links('vendor.pagination.luxury') }}
@@ -547,9 +322,9 @@
- - +
@@ -598,16 +373,14 @@ class="text-xl font-mono font-black text-slate-700 dark:text-slate-200 tracking-wider" x-text="calculateExpiry()">
-

- {{ __('Leave empty or 0 for permanent code') }}

- + @@ -617,36 +390,26 @@
- {{-- Confirm Cancel Modal --}} - - - {{-- QR Code Modal --}} -
-
-
-
+
+
-
- -
-
-

{{ - __('Pass Code QR') }}

-

-
-
+
+ @csrf + @method('PATCH') +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ + {{-- QR Modal --}} +
+
+
+
+
+ +
+
+
+

{{ __('QR Code') }} +

+

+ {{ __('Pass Code') }}: +

+
+ +
+
@@ -661,7 +487,7 @@

- {{ __('Scan this code at the machine to authorize testing or maintenance.') }} + {{ __('Scan this code at the machine or share the link with the maintenance staff.') }}

@@ -671,7 +497,7 @@ title="{{ __('Copy Link') }}"> + d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 006.364 6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" /> {{ __('Link') }} @@ -710,82 +536,9 @@
- {{-- Edit Modal --}} -
-
-
- -
-
-

{{ - __('Edit Pass Code') }}

- -
- -
- @csrf - @method('PATCH') - -
- - -
- -
- - -

{{ __('Leave - empty for permanent code') }}

-
- -
- - -
-
-
-
-
+ {{-- Delete Modal --}} +
-@endsection - -@push('styles') - -@endpush \ No newline at end of file +@endsection \ No newline at end of file diff --git a/resources/views/admin/sales/pass-codes/partials/tab-list.blade.php b/resources/views/admin/sales/pass-codes/partials/tab-list.blade.php new file mode 100644 index 0000000..4257b7c --- /dev/null +++ b/resources/views/admin/sales/pass-codes/partials/tab-list.blade.php @@ -0,0 +1,300 @@ +
+ +
+ + + + + + + +
+ +
+ + + + + +
+ +
+ + +
+
+ +{{-- Table (Desktop) --}} + + +{{-- Card Grid (Mobile) --}} +
+ @forelse ($passCodes as $code) +
+ {{-- Card Header --}} +
+
+
+ + + +
+
+ @if($code->status === 'active' && (!$code->expires_at || $code->expires_at->isFuture())) +

+ {{ $code->code }} +

+ @else +

+ {{ $code->code }} +

+ @endif +

+ {{ $code->name }} +

+
+
+
+ @php + $displayStatusMobile = $code->status === 'active' && $code->expires_at?->isPast() ? + 'expired' : ($code->status === 'disabled' ? 'cancelled' : $code->status); + @endphp + +
+
+ + {{-- Info Grid --}} +
+
+

+ {{ __('Machine') }}

+

{{ + $code->machine->name }}

+
+
+

+ {{ __('Expires At') }}

+

+ {{ $code->expires_at ? $code->expires_at->format('Y-m-d H:i') : __('Permanent') }} +

+
+
+ + {{-- Action Buttons --}} +
+ @if($code->status === 'active' && (!$code->expires_at || $code->expires_at->isFuture())) + + + @endif + + @if($code->status === 'active') +
+ @csrf + @method('DELETE') + +
+ @endif +
+
+ @empty +
+ +
+ @endforelse +
+ +{{-- Pagination --}} +
+ {{ $passCodes->links('vendor.pagination.luxury') }} +
diff --git a/resources/views/admin/sales/pass-codes/partials/tab-logs.blade.php b/resources/views/admin/sales/pass-codes/partials/tab-logs.blade.php new file mode 100644 index 0000000..e9a938a --- /dev/null +++ b/resources/views/admin/sales/pass-codes/partials/tab-logs.blade.php @@ -0,0 +1,289 @@ +{{-- Filter Form --}} +
+ + + {{-- 搜尋關鍵字 --}} +
+ + + + + + + +
+ + {{-- 類型篩選 --}} +
+ + @foreach($actions as $key => $label) + + @endforeach + +
+ + {{-- 日期範圍 --}} +
+ + + + + + +
+ + {{-- 按鈕組 --}} +
+ + +
+
+ +{{-- Desktop Table Mode --}} + + +{{-- Card Mode (Mobile & Tablet) --}} +
+ @forelse($logs as $log) +
+
+
+
+ + + +
+
+

+ {{ __('Pass Code') }}

+

+ ID: {{ $log->target_id }}

+
+
+
+ @php + $actionColor = match($log->action) { + 'create' => 'cyan', + 'cancel' => 'rose', + 'update' => 'indigo', + 'verify_success' => 'emerald', + default => 'slate' + }; + @endphp + + {{ __($log->action) }} + +
+
+ +
+
+

{{ + __('Operator') }}

+

{{ + $log->user->name ?? __('System') }}

+
+
+

{{ + __('Pass Code') }}

+

+ {{ $log->new_values['code'] ?? '-' }} +

+
+
+

{{ + __('Time') }}

+

+ {{ $log->created_at?->format('Y-m-d H:i:s') }}

+
+
+
+ @empty +
+ +
+ @endforelse +
+ +{{-- Pagination --}} +
+ {{ $logs->links('vendor.pagination.luxury') }} +
diff --git a/resources/views/admin/sales/pickup-codes/index.blade.php b/resources/views/admin/sales/pickup-codes/index.blade.php index 3e3ca30..58d178b 100644 --- a/resources/views/admin/sales/pickup-codes/index.blade.php +++ b/resources/views/admin/sales/pickup-codes/index.blade.php @@ -6,18 +6,36 @@ $slotSelectConfig = [ "hasSearch" => true, "searchPlaceholder" => __("Search Slot..."), "isHidePlaceholder" => false, -"searchClasses" => "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500", +"searchClasses" => "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg +focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 +placeholder:text-slate-400 dark:placeholder:text-slate-500", "searchWrapperClasses" => "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10", "toggleClasses" => "hs-select-toggle luxury-select-toggle", -"toggleTemplate" => '', -"dropdownClasses" => "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[150] animate-luxury-in", -"optionClasses" => "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between transition-all duration-300", -"optionTemplate" => '
' +"toggleTemplate" => '', +"dropdownClasses" => "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 +rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[150] animate-luxury-in", +"optionClasses" => "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer +hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between +transition-all duration-300", +"optionTemplate" => '
' ]; @endphp @section('content') -
+
@@ -34,342 +52,43 @@ $slotSelectConfig = [ {{-- Tab Navigation --}} -
- - - - -
+ + + + - {{-- Content Containers --}} -
- {{-- List Tab --}} -
- - {{-- Main Content Card --}} -
- - -
- -
- -
- - - - - - - -
- -
- - - - - - -
- -
- - -
-
- - {{-- Table (Desktop) --}} - - - {{-- Card Grid (Mobile) --}} -
- @forelse ($pickupCodes as $code) -
- {{-- Card Header --}} -
-
-
- - - -
-
- @if($code->status === 'active' && $code->expires_at->isFuture()) -

- {{ $code->code }} -

- @else -

- {{ $code->code }} -

- @endif -

- {{ $code->machine->name }} -

-
-
- @php - $displayStatus = $code->status === 'active' && $code->expires_at->isPast() ? 'expired' : - $code->status; - @endphp - -
- - {{-- Info Grid --}} -
-
-

- {{ __('Slot') }}

-

- {{ $code->slot_no }} - @php - $slotMobile = $code->machine->slots->where('slot_no', $code->slot_no)->first(); - $prodMobile = $slotMobile ? ($slotMobile->product?->name ?? __('Empty')) : __('Empty'); - @endphp - ({{ $prodMobile }}) -

-
-
-

- {{ __('Expiry') }}

-

- {{ $code->expires_at->format('m-d H:i') }} - {{ $code->expires_at->diffForHumans() - }} -

-
-
- - {{-- Action Buttons --}} -
- @if($code->status === 'active' && $code->expires_at->isFuture()) - {{-- QR Button (Mobile) --}} - - @endif - - @if($code->status === 'active' && $code->expires_at->isFuture()) -
- @csrf - @method('DELETE') - -
- @endif + {{-- Tab Contents --}} +
+
+ {{-- List Tab --}} +
+
+ +
+ @include('admin.sales.pickup-codes.partials.tab-list')
- @empty -
- -
- @endforelse
+ {{-- End List Tab --}} - {{-- Pagination --}} -
- {{ $pickupCodes->links('vendor.pagination.luxury') }} -
-
{{-- End tab-list-container --}} -
{{-- End luxury-card --}} -
- {{-- End List Tab --}} - - {{-- Logs Tab --}} - {{-- End relative min-h --}} +
{{-- End mt-6 --}} {{-- Create Modal --}}
- +
@@ -474,10 +192,8 @@ $slotSelectConfig = [
- {{ - __('Expected Expiry') }} - {{ __('Max 24 - Hours') }} + {{ __('Expected Expiry') }} + {{ __('Max 24 Hours') }}
@@ -610,8 +326,8 @@ $slotSelectConfig = [ activeQrCode: '', activeTicketUrl: '', slots: [], + tabLoading: null, loadingSlots: false, - isLoadingTable: false, showCancelModal: false, cancelTargetForm: null, slotSelectConfig: config, @@ -619,17 +335,32 @@ $slotSelectConfig = [ - async fetchTabData(tab, containerSelector) { + async fetchTabData(tab, url = null) { if (this.tabLoading === tab) return; this.tabLoading = tab; + const container = document.getElementById('tab-' + tab + '-container'); - const container = document.querySelector(containerSelector); - let url = '{{ route("admin.sales.pickup-codes") }}'; - - const urlObj = new URL(url, window.location.origin); - urlObj.searchParams.set('tab', tab); - urlObj.searchParams.set('_ajax', '1'); - url = urlObj.toString(); + if (!url) { + let params = new URLSearchParams(); + params.set('tab', tab); + params.set('_ajax', '1'); + + if (container) { + const form = container.querySelector('form'); + if (form) { + const formData = new FormData(form); + formData.forEach((value, key) => { + if (value.trim() !== '') params.append(key, value); + }); + } + } + url = `${window.location.pathname}?${params.toString()}`; + } else { + const urlObj = new URL(url, window.location.origin); + urlObj.searchParams.set('tab', tab); + urlObj.searchParams.set('_ajax', '1'); + url = urlObj.toString(); + } try { const response = await fetch(url, { @@ -706,25 +437,7 @@ $slotSelectConfig = [ this.$el.submit(); }, - async fetchPage(url) { - if (!url || this.isLoadingTable) return; - this.isLoadingTable = true; - try { - const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); - const html = await res.text(); - const doc = new DOMParser().parseFromString(html, 'text/html'); - const newContent = doc.querySelector('#ajax-content-container'); - if (newContent) { - document.querySelector('#ajax-content-container').innerHTML = newContent.innerHTML; - window.history.pushState({}, '', url); - if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit(); - } - } catch (e) { - console.error('AJAX Load Failed:', e); - } finally { - this.isLoadingTable = false; - } - }, + copyQrLink() { if (!this.activeTicketUrl) return; @@ -841,9 +554,8 @@ $slotSelectConfig = [ url.searchParams.set('tab', newTab); window.history.pushState({}, '', url); - if (newTab === 'logs') { - this.fetchTabData('logs', '#tab-logs-container'); - } + // Tab data is pre-rendered; refresh only via filters/pagination or specific actions + this.$nextTick(() => { if (window.HSStaticMethods) window.HSStaticMethods.autoInit(); diff --git a/resources/views/admin/sales/pickup-codes/partials/tab-list.blade.php b/resources/views/admin/sales/pickup-codes/partials/tab-list.blade.php new file mode 100644 index 0000000..6ac398a --- /dev/null +++ b/resources/views/admin/sales/pickup-codes/partials/tab-list.blade.php @@ -0,0 +1,310 @@ +
+ +
+ + + + + + + +
+ +
+ + + + + + +
+ +
+ + +
+
+ +{{-- Table (Desktop) --}} + + +{{-- Card Grid (Mobile) --}} +
+ @forelse ($pickupCodes as $code) +
+ {{-- Card Header --}} +
+
+
+ + + +
+
+ @if($code->status === 'active' && $code->expires_at->isFuture()) +

+ {{ $code->code }} +

+ @else +

+ {{ $code->code }} +

+ @endif +

+ {{ $code->machine->name }} +

+
+
+ @php + $displayStatus = $code->status === 'active' && $code->expires_at->isPast() ? + 'expired' : + $code->status; + @endphp + +
+ + {{-- Info Grid --}} +
+
+

+ {{ __('Slot') }}

+

+ {{ $code->slot_no }} + @php + $slotMobile = $code->machine->slots->where('slot_no', + $code->slot_no)->first(); + $prodMobile = $slotMobile ? ($slotMobile->product?->name ?? __('Empty')) : + __('Empty'); + @endphp + ({{ $prodMobile + }}) +

+
+
+

+ {{ __('Expiry') }}

+

+ {{ $code->expires_at->format('m-d H:i') }} + {{ + $code->expires_at->diffForHumans() + }} +

+
+
+ + {{-- Action Buttons --}} +
+ @if($code->status === 'active' && $code->expires_at->isFuture()) + {{-- QR Button (Mobile) --}} + + @endif + + @if($code->status === 'active' && $code->expires_at->isFuture()) +
+ @csrf + @method('DELETE') + +
+ @endif +
+
+ @empty +
+ +
+ @endforelse +
+ +{{-- Pagination --}} +
+ {{ $pickupCodes->links('vendor.pagination.luxury') }} +
diff --git a/resources/views/admin/sales/pickup-codes/partials/tab-logs.blade.php b/resources/views/admin/sales/pickup-codes/partials/tab-logs.blade.php index 8611040..5378a1e 100644 --- a/resources/views/admin/sales/pickup-codes/partials/tab-logs.blade.php +++ b/resources/views/admin/sales/pickup-codes/partials/tab-logs.blade.php @@ -1,98 +1,288 @@ -
-
- - - - - - - - - - - - @forelse($logs as $log) - - - - - - - - @empty - - - - @endforelse - -
- {{ __('Time') }} - - {{ __('Operator') }} - - {{ __('Actions') }} - - {{ __('Target') }} - - {{ __('Notes') }} -
- {{ $log->created_at->format('Y-m-d H:i:s') }} - -
-
- {{ Str::substr($log->user->name ?? 'Sys', 0, 1) }} -
- - {{ $log->user->name ?? __('System') }} - -
-
- @if($log->action === 'create') - - {{ __('Generate') }} - - @elseif($log->action === 'update') - - {{ __('Update') }} - - @elseif($log->action === 'cancel') - - {{ __('Cancel') }} - - @else - - {{ $log->action }} - - @endif - - ID: {{ $log->target_id }} - @if($log->new_values && isset($log->new_values['code'])) - - {{ $log->new_values['code'] }} - - @endif - - @if($log->action === 'update' && isset($log->old_values['expires_at']) && isset($log->new_values['expires_at'])) -
{{ __('Expires At') }}: - {{ \Carbon\Carbon::parse($log->old_values['expires_at'])->format('Y-m-d H:i') }} - - {{ \Carbon\Carbon::parse($log->new_values['expires_at'])->format('Y-m-d H:i') }} -
- @endif -
-
- - - -

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

-
-
+{{-- Filter Form (Simplified to match Inventory style) --}} +
+ + + {{-- 搜尋關鍵字 --}} +
+ + + + + + +
- - @if(isset($logs) && $logs instanceof \Illuminate\Pagination\LengthAwarePaginator && $logs->hasPages()) -
- {{ $logs->links('vendor.pagination.luxury') }} -
- @endif + {{-- 類型篩選 --}} +
+ + @foreach($actions as $key => $label) + + @endforeach + +
+ + {{-- 日期範圍 --}} +
+ + + + + + +
+ + {{-- 按鈕組 --}} +
+ + +
+
+ +{{-- Desktop Table Mode --}} + + +{{-- Card Mode (Mobile & Tablet) --}} +
+ @forelse($logs as $log) +
+
+
+
+ + + +
+
+

+ {{ __('Pickup Code') }}

+

+ ID: {{ $log->target_id }}

+
+
+
+ @php + $actionColor = match($log->action) { + 'create' => 'cyan', + 'cancel', 'consume_failed' => 'rose', + 'used', 'consume' => 'emerald', + 'verify_success' => 'indigo', + default => 'slate' + }; + @endphp + + {{ __($log->action) }} + +
+
+ +
+
+

{{ + __('Operator') }}

+

{{ + $log->user->name ?? __('System') }}

+
+
+

{{ + __('Pickup Code') }}

+

+ {{ $log->new_values['code'] ?? '-' }} +

+
+
+

{{ + __('Time') }}

+

+ {{ $log->created_at?->format('Y-m-d H:i:s') }}

+
+
+
+ @empty +
+ +
+ @endforelse +
+ +{{-- Pagination --}} +
+ {{ $logs->links('vendor.pagination.luxury') }}
diff --git a/resources/views/admin/warehouses/machine-inventory.blade.php b/resources/views/admin/warehouses/machine-inventory.blade.php index 5e95023..2a4f4ec 100644 --- a/resources/views/admin/warehouses/machine-inventory.blade.php +++ b/resources/views/admin/warehouses/machine-inventory.blade.php @@ -1,135 +1,7 @@ @extends('layouts.admin') @section('content') -
+
{{-- Header --}}