diff --git a/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php b/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php index 4de285d..05a0e02 100644 --- a/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php +++ b/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php @@ -369,7 +369,8 @@ class MachineSettingController extends AdminController { $machines = Machine::whereNotNull('latitude') ->whereNotNull('longitude') - ->get(['id', 'name', 'serial_no', 'address', 'location', 'latitude', 'longitude', 'status']); + ->with('machineModel') + ->get(['id', 'name', 'serial_no', 'address', 'location', 'latitude', 'longitude', 'status', 'machine_model_id']); return view('admin.basic-settings.machines.distribution', compact('machines')); } diff --git a/app/Http/Controllers/Admin/SalesController.php b/app/Http/Controllers/Admin/SalesController.php index 9e10917..5a3993d 100644 --- a/app/Http/Controllers/Admin/SalesController.php +++ b/app/Http/Controllers/Admin/SalesController.php @@ -42,11 +42,12 @@ class SalesController extends Controller }); } - if ($request->status) { - $query->where('status', $request->status); + if ($request->status && trim($request->status) !== '') { + $query->where('status', trim($request->status)); } - $pickupCodes = $query->paginate(15)->withQueryString(); + $per_page = $request->input('per_page', 15); + $pickupCodes = $query->paginate($per_page)->withQueryString(); // 供新增彈窗使用的機台清單 $machines = Machine::all(); diff --git a/app/Http/Controllers/Api/V1/App/MachineController.php b/app/Http/Controllers/Api/V1/App/MachineController.php index 08a3b43..b17521b 100644 --- a/app/Http/Controllers/Api/V1/App/MachineController.php +++ b/app/Http/Controllers/Api/V1/App/MachineController.php @@ -14,6 +14,7 @@ use App\Jobs\Machine\ProcessStateLog; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Cache; +use App\Models\Transaction\PickupCode; class MachineController extends Controller { @@ -490,6 +491,86 @@ class MachineController extends Controller 'success' => true, 'code' => 200, 'data' => [$data] // App 預期的是包含單一物件的陣列 + } + + /** + * B660: Verify Pickup Code (with Lockout) + * 驗證 4 位數取貨碼,並具備連續錯誤鎖定機制 + */ + 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); + } + + $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); + } + + $code = $request->input('code'); + + $pickupCode = PickupCode::where('machine_id', $machine->id) + ->where('code', $code) + ->where('status', 'active') + ->where('expires_at', '>', now()) + ->first(); + + if (!$pickupCode) { + // 追蹤失敗次數 + $failKey = "pickup_fails:{$machine->id}"; + $fails = Cache::increment($failKey); + + // 第一次失敗,設定計數器的過期時間 (例如 10 分鐘內累計) + if ($fails === 1) { + Cache::put($failKey, 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); + } + + return response()->json([ + 'success' => false, + 'message' => 'Invalid code', + 'code' => 404, + 'remaining_attempts' => 5 - $fails + ], 404); + } + + // 驗證成功:清除失敗紀錄 + Cache::forget("pickup_fails:{$machine->id}"); + + return response()->json([ + 'success' => true, + 'message' => 'Valid code', + 'code' => 200, + 'data' => [ + 'slot_no' => $pickupCode->slot_no, + 'pickup_code_id' => $pickupCode->id, + 'status' => 'active' + ] ]); } } diff --git a/app/Http/Controllers/Guest/PickupController.php b/app/Http/Controllers/Guest/PickupController.php new file mode 100644 index 0000000..0909faa --- /dev/null +++ b/app/Http/Controllers/Guest/PickupController.php @@ -0,0 +1,27 @@ +where(function($query) use ($slug) { + $query->where('slug', $slug) + ->orWhere('code', $slug); // 相容舊版或測試 + }) + ->where('status', 'active') + ->where('expires_at', '>', now()) + ->firstOrFail(); + + return view('guest.pickup.show', compact('pickupCode')); + } +} diff --git a/app/Models/Transaction/PickupCode.php b/app/Models/Transaction/PickupCode.php index c72bc88..a1c7be4 100644 --- a/app/Models/Transaction/PickupCode.php +++ b/app/Models/Transaction/PickupCode.php @@ -17,12 +17,31 @@ class PickupCode extends Model 'machine_id', 'slot_no', 'code', + 'slug', 'expires_at', 'used_at', 'status', 'created_by', ]; + /** + * 獲取公開取貨連結 + */ + public function getTicketUrlAttribute() + { + return $this->slug ? route('pickup.ticket', $this->slug) : route('pickup.ticket', $this->code); + } + + /** + * Boot the model. + */ + protected static function booted() + { + static::creating(function ($pickupCode) { + $pickupCode->slug = \Illuminate\Support\Str::random(16); + }); + } + protected $casts = [ 'expires_at' => 'datetime', 'used_at' => 'datetime', @@ -53,12 +72,12 @@ class PickupCode extends Model } /** - * 產生唯一的取貨碼 (4位) + * 產生唯一的取貨碼 (8位) */ public static function generateUniqueCode(int $machineId): string { do { - $code = str_pad(rand(0, 9999), 4, '0', STR_PAD_LEFT); + $code = str_pad(rand(0, 99999999), 8, '0', STR_PAD_LEFT); $exists = self::where('machine_id', $machineId) ->where('code', $code) ->where('status', 'active') diff --git a/database/migrations/2026_04_27_143339_create_pickup_codes_table.php b/database/migrations/2026_04_27_143339_create_pickup_codes_table.php index 4797390..e538c34 100644 --- a/database/migrations/2026_04_27_143339_create_pickup_codes_table.php +++ b/database/migrations/2026_04_27_143339_create_pickup_codes_table.php @@ -16,7 +16,7 @@ return new class extends Migration $table->foreignId('company_id')->constrained('companies')->onDelete('cascade'); $table->foreignId('machine_id')->constrained('machines')->onDelete('cascade'); $table->string('slot_no')->comment('貨道編號'); - $table->string('code', 10)->comment('取貨碼 (通常為 4 位)'); + $table->string('code', 10)->comment('取貨碼 (8 位)'); $table->timestamp('expires_at')->comment('到期時間'); $table->timestamp('used_at')->nullable()->comment('使用時間'); $table->string('status')->default('active')->comment('狀態: active, used, expired, cancelled'); diff --git a/database/migrations/2026_04_29_094145_add_slug_to_pickup_codes_table.php b/database/migrations/2026_04_29_094145_add_slug_to_pickup_codes_table.php new file mode 100644 index 0000000..8cb0403 --- /dev/null +++ b/database/migrations/2026_04_29_094145_add_slug_to_pickup_codes_table.php @@ -0,0 +1,28 @@ +string('slug', 32)->nullable()->unique()->after('code'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('pickup_codes', function (Blueprint $table) { + // + }); + } +}; diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 347930b..cfee4d4 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -177,7 +177,6 @@ "by": "由", "Calculate Replenishment": "計算補貨量", "Cancel": "取消", - "Cancel Code": "取消代碼", "Cancel Order": "取消訂單", "Cancel Purchase": "取消購買", "Cancelled": "已取消", @@ -529,7 +528,6 @@ "Functional Settings": "功能設定", "Games": "互動遊戲", "General permissions not linked to a specific menu.": "未連結到特定選單的一般權限。", - "Generate": "產生", "Generate and manage one-time pickup codes for customers": "產生並管理客戶的一次性取貨碼", "Generate New Code": "產生新代碼", "Generate Pickup Code": "產生取貨碼", @@ -626,7 +624,8 @@ "Loading...": "載入中...", "Location": "位置", "Location found!": "已成功獲取座標!", - "Location not found": "找不到該地址的座標", + "Location not found": "找不到位置,您可以嘗試使用地標名稱(例如:花蓮縣政府)", + "Try using landmark names (e.g., Hualien County Government)": "您可以嘗試使用地標名稱(例如:花蓮縣政府)", "Lock": "鎖定", "Lock Now": "立即鎖定", "Lock Page": "頁面鎖定", @@ -1002,6 +1001,12 @@ "Picked up Time": "領取時間", "Machine / Slot": "機台 / 貨道", "Pickup Code": "取貨碼", + "Pickup code generated: :code": "已生成取貨碼::code", + "Pickup code updated.": "取貨碼已更新", + "Pickup code cancelled.": "取貨碼已取消", + "Pass code created: :code": "通行碼已建立::code", + "Pass code updated.": "通行碼已更新", + "Pass code deleted.": "通行碼已刪除", "Pickup Codes": "取貨碼", "Pickup door closed": "取貨門已關閉", "Pickup door error": "取貨門運作異常", @@ -1170,7 +1175,6 @@ "Sales Management": "銷售管理", "Sales Permissions": "銷售管理權限", "Sales Records": "銷售紀錄", - "Save": "儲存變更", "Save Changes": "儲存變更", "Save Config": "儲存配置", "Save error:": "儲存錯誤:", @@ -1302,6 +1306,29 @@ "Start Delivery": "開始配送", "Statistics": "數據統計", "Status": "狀態", + "Search by code, machine name or serial...": "搜尋代碼、機台名稱或序號...", + "Product / Slot": "商品 / 貨道", + "Pickup Ticket": "取貨憑證", + "Valid Until": "有效期限至", + "Valid Ticket": "有效憑證", + "Scan to pick up your product": "掃描以領取您的商品", + "Unknown Product": "未知商品", + "Ticket": "憑證", + "QR": "二維碼", + "QR CODE": "二維碼", + "Expires At": "到期時間", + "Copy Link": "複製連結", + "Download Image": "下載圖片", + "Image Downloaded": "圖片已下載", + "Link Copied": "連結已複製", + "Scan this code at the machine or share the link with the customer.": "請在機台掃描此碼,或將連結分享給客戶。", + "Close": "關閉", + "Copy": "複製", + "Select Slot": "選擇貨道", + "Validity Period": "有效期限", + "Max 24 Hours": "最長 24 小時", + "Expected Expiry Date & Time": "預計到期時間", + "Please select a machine": "請選擇機台", "Status / Temp / Sub / Card / Scan": "狀態 / 溫度 / 下位機 / 刷卡機 / 掃碼機", "Status Timeline": "狀態時間線", "Status updated successfully": "狀態更新成功", @@ -1487,15 +1514,13 @@ "Utilization Timeline": "稼動時序", "Utilization, OEE and Operational Intelligence": "稼動率、OEE 與營運情報", "Utilized Time": "稼動持續時間", - "Valid Until": "合約到期日", + "Expiry Time": "到期時間", "Validation Error": "驗證錯誤", "Vending": "販賣頁", "vending": "販賣頁", "Vending Page": "販賣頁", "Venue Management": "場地管理", "Used": "已使用", - "Validity Period (Days)": "有效期限 (天)", - "Validity Period (Hours)": "有效期限 (小時)", "Video": "影片", "video": "影片", "View all warehouses": "查看所有倉庫", @@ -1564,27 +1589,22 @@ "Tenant": "客戶", "Permission Tags": "權限標籤", "All Permissions": "全部權限", - "All Status": "所有狀態", - "Select Slot": "選擇貨道", - "Validity Period (Hours)": "有效期限 (小時)", - "Hrs": "小時", - "No Actions": "無操作", - "Target Machine": "目標機台", "Description / Name": "描述 / 名稱", "Pass Code (8 Digits)": "通行碼 (8 位數)", - "Regenerate": "重新產生", "Validity Period (Days)": "有效期限 (天)", "Leave empty for permanent code": "留空表示永久有效", "Permanent": "永久有效", "Add Pickup Code": "新增取貨碼", "Add Pass Code": "新增通行碼", - "QR": "QR Code", "Scan QR Code": "掃描 QR Code", "View QR Code": "查看 QR Code", "Expected Expiry": "預計過期時間", "Max :max hours": "最多 :max 小時", - "Expected Expiry Date & Time": "預計過期日期與時間", - "Validity Period": "有效期限", - "Max 24 Hours": "最多 24 小時", - "Please select a machine": "請選擇機台" + "Cancel Pickup Code": "取消取貨碼", + "Are you sure you want to cancel this pickup code? This action cannot be undone.": "確定要取消此取貨碼嗎?此操作無法復原。", + "Yes, Cancel": "確認取消", + "Delete Pass Code": "刪除通行碼", + "Are you sure you want to delete this pass code?": "確定要刪除此通行碼嗎?", + "Yes, Delete": "確認刪除", + "Delete Code": "刪除代碼" } \ No newline at end of file diff --git a/resources/views/admin/basic-settings/machines/distribution.blade.php b/resources/views/admin/basic-settings/machines/distribution.blade.php index e6962a2..d6a3cf1 100644 --- a/resources/views/admin/basic-settings/machines/distribution.blade.php +++ b/resources/views/admin/basic-settings/machines/distribution.blade.php @@ -60,27 +60,101 @@ {{ __('Machine Distribution') }}

+ + + @php + $groupedByModel = $machines->groupBy(fn($m) => $m->machineModel?->name ?? __('Unknown Model')); + $modelColors = [ + 'U4' => '#EAB308', // Yellow + 'U3' => '#15803D', // Green + 'U1' => '#F97316', // Orange + 'U4S' => '#0D9488', // Teal + 'I1' => '#854D0E', // Olive + 'U4SSS' => '#EF4444', // Red + 'Y1' => '#F97316', // Orange + 'U4SS' => '#3B82F6', // Blue + 'U4S(短)' => '#1E3A8A', // Dark Blue + 'U4XXXS' => '#64748B', // Slate + '易豐主機' => '#22C55E', // Bright Green + 'I10' => '#A855F7', // Purple + 'N1' => '#BEF264', // Lime + 'Y2' => '#DC2626', // Red + 'U4_Android9_test' => '#10b981', // Emerald + ]; + + // Unify fallback color logic with JS + $getFallbackColor = function($name) { + $hash = 0; + $len = mb_strlen($name); + for ($i = 0; $i < $len; $i++) { + $char = mb_substr($name, $i, 1); + $code = mb_ord($char); + $hash = $code + (($hash << 5) - $hash); + $hash = $hash & 0xFFFFFFFF; // Keep it 32-bit + } + return sprintf('#%06X', $hash & 0xFFFFFF); + }; + @endphp -
+
+
+

{{ __('Statistics: Model') }}

+
+
+ @foreach($groupedByModel as $modelName => $group) + @php $color = $modelColors[$modelName] ?? $getFallbackColor($modelName); @endphp +
+
+ + {{ $modelName }} +
+ ({{ $group->count() }}) +
+ @endforeach +
+
+ + +
+
+ + + + +
+
+ +
@foreach($machines as $machine) -
machineModel?->name ?? __('Unknown Model'); + $color = $modelColors[$modelName] ?? $getFallbackColor($modelName); + @endphp +
- - {{ $machine->name }} - +
+ + + {{ $machine->name }} + +
- - {{ __($machine->status) }} + + {{ __($machine->status) }}
-

+

{{ $machine->address ?: $machine->location ?: __('No address information') }}

-
+
{{ $machine->serial_no }} + {{ $modelName }}
@endforeach @@ -91,6 +165,9 @@ {{ __('Total Machines') }} {{ $machines->count() }}
+
@@ -122,6 +199,17 @@ @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 14e9044..673fb73 100644 --- a/resources/views/admin/sales/pass-codes/index.blade.php +++ b/resources/views/admin/sales/pass-codes/index.blade.php @@ -3,6 +3,8 @@ @section('content')
{{-- Page Header --}} @@ -126,10 +141,10 @@
-
+ @csrf @method('DELETE') -
+ + {{-- Confirm Delete Modal --}} + @endsection diff --git a/resources/views/admin/sales/pickup-codes/index.blade.php b/resources/views/admin/sales/pickup-codes/index.blade.php index 9d91842..e680a48 100644 --- a/resources/views/admin/sales/pickup-codes/index.blade.php +++ b/resources/views/admin/sales/pickup-codes/index.blade.php @@ -26,11 +26,11 @@ :title="__('Pickup Codes')" :subtitle="__('Generate and manage one-time pickup codes for customers')" > - @@ -38,7 +38,10 @@
-
+
- + + + +
- + + + + + +
-
@@ -89,9 +100,6 @@ {{ __('Product / Slot') }} - - {{ __('QR') }} - {{ __('Expires At') }} @@ -107,9 +115,16 @@ @forelse ($pickupCodes as $code) - - {{ $code->code }} - + @if($code->status === 'active' && $code->expires_at->isFuture()) + + {{ $code->code }} + + @else + + {{ $code->code }} + + @endif
{{ $code->machine->name }}
@@ -123,13 +138,6 @@
{{ $productName }}
{{ __('Slot') }}: {{ $code->slot_no }}
- - -
{{ $code->expires_at->format('Y-m-d H:i') }}
{{ $code->expires_at->diffForHumans() }}
@@ -143,12 +151,24 @@
@if($code->status === 'active' && $code->expires_at->isFuture()) -
+ {{-- View QR Code Button --}} + + @endif + + @if($code->status === 'active' && $code->expires_at->isFuture()) + @csrf @method('DELETE') -
-

- {{ $code->code }} -

+ @if($code->status === 'active' && $code->expires_at->isFuture()) +

+ {{ $code->code }} +

+ @else +

+ {{ $code->code }} +

+ @endif

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

@@ -209,33 +236,31 @@
- {{-- QR Button (Mobile) --}} -
- -
- {{-- 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') -
@@ -354,41 +379,81 @@
{{-- QR Modal --}} -
-
-
- -
- -
-
- - - +
+
+
+
+
+ +
+
+
+

{{ __('QR Code') }}

+

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

-

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

-

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

+
+ +
+
+ +
+ +
+

+ {{ __('Scan this code at the machine or share the link with the customer.') }} +

+ +
+ {{-- Copy Link Button --}} + -
- + {{-- Download Image Button --}} + +
+
+
+ +
+
- -
+ + {{-- Confirm Cancel Modal --}} +