diff --git a/app/Http/Controllers/Admin/SalesController.php b/app/Http/Controllers/Admin/SalesController.php index 01f8f24..9a735be 100644 --- a/app/Http/Controllers/Admin/SalesController.php +++ b/app/Http/Controllers/Admin/SalesController.php @@ -19,6 +19,7 @@ use App\Models\Machine\Machine; use Carbon\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use App\Models\System\SystemOperationLog; @@ -373,6 +374,11 @@ class SalesController extends Controller 'tab' => $tab, ]; + // 批次產生數量上限/快捷選項依角色分級:平台管理員 1000(10/50/100/1000)、客戶端 20(10/20) + $isSystemAdmin = Auth::user()->isSystemAdmin(); + $data['maxBatchQuantity'] = $isSystemAdmin ? 1000 : 20; + $data['batchQuickOptions'] = $isSystemAdmin ? [10, 50, 100, 1000] : [10, 20]; + // 1. 取貨碼列表 (list) if (!$isAjax || $tab === 'list') { // 僅顯示目前帳號可存取機台的取貨碼(機台授權以 machine_user 為準,whereHas 會套用 Machine 的 machine_access 全域 scope) @@ -474,6 +480,10 @@ class SalesController extends Controller */ public function storePickupCode(Request $request) { + // 批次數量上限依角色分級:平台管理員最多 1000 筆;客戶端(租戶)最多 20 筆。 + // 後端強制把關,避免前端被繞過送出大量產碼請求。 + $maxQuantity = Auth::user()->isSystemAdmin() ? 1000 : 20; + $validated = $request->validate([ 'machine_id' => 'required|exists:machines,id', // 取貨碼改綁「商品」:新流程傳 product_id;舊的綁「貨道」流程仍可傳 slot_no(向後相容)。 @@ -483,9 +493,11 @@ class SalesController extends Controller 'expires_hours' => 'nullable|integer|min:1|max:720', 'expires_at' => 'nullable|date|after:now', 'custom_code' => 'nullable|string|min:4|max:12', + 'quantity' => 'nullable|integer|min:1|max:' . $maxQuantity, ]); $machine = Machine::findOrFail($validated['machine_id']); + $quantity = (int) ($validated['quantity'] ?? 1); // 綁商品時驗證該商品屬於此機台所屬公司(不要求機台當下有貨道;機台未上架仍可產碼, // 由前端提示、刷碼時若無貨道再由 B660 擋下)。 @@ -502,6 +514,66 @@ class SalesController extends Controller $expiresAt = $request->filled('expires_at') ? Carbon::parse($validated['expires_at']) : now()->addHours((int) ($request->expires_hours ?? 24)); + $usageLimit = $validated['usage_limit'] ?? 1; + + // 批次產生(>1 筆):忽略自訂碼,系統自動產生隨機唯一碼,並以 batch_no 標記同一批。 + // 沿用單筆的綁定方式(優先 product_id,相容舊的 slot_no)。 + if ($quantity > 1) { + $batchNo = 'PUB' . now()->format('YmdHis') . strtoupper(Str::random(4)); + $codes = $this->generateUniqueCodeBatch(PickupCode::class, $machine->id, $quantity); + $nowTs = now(); + + $rows = []; + foreach ($codes as $code) { + $rows[] = [ + 'company_id' => $machine->company_id, + 'machine_id' => $machine->id, + 'product_id' => $validated['product_id'] ?? null, + 'slot_no' => $validated['slot_no'] ?? null, + 'code' => $code, + 'slug' => Str::random(16), + 'batch_no' => $batchNo, + 'status' => 'active', + 'usage_limit' => $usageLimit, + 'usage_count' => 0, + 'expires_at' => $expiresAt, + 'created_by' => Auth::id(), + 'created_at' => $nowTs, + 'updated_at' => $nowTs, + ]; + } + + DB::transaction(function () use ($rows) { + foreach (array_chunk($rows, 500) as $chunk) { + PickupCode::insert($chunk); + } + }); + + SystemOperationLog::create([ + 'company_id' => $machine->company_id, + 'user_id' => Auth::id(), + 'module' => 'pickup_code', + 'action' => 'batch_create', + 'target_id' => null, + 'target_type' => PickupCode::class, + 'new_values' => [ + 'batch_no' => $batchNo, + 'count' => count($rows), + 'machine_id' => $machine->id, + 'product_id' => $validated['product_id'] ?? null, + 'slot_no' => $validated['slot_no'] ?? null, + ], + ]); + + return back() + ->with('success', __(':count pickup codes generated', ['count' => count($rows)])) + ->with('code_batch', [ + 'type' => 'pickup', + 'batch_no' => $batchNo, + 'count' => count($rows), + 'download_url' => route('admin.sales.pickup-codes.batch-download', $batchNo), + ]); + } $pickupCode = PickupCode::create([ 'machine_id' => $validated['machine_id'], @@ -641,6 +713,11 @@ class SalesController extends Controller 'tab' => $tab, ]; + // 批次產生數量上限/快捷選項依角色分級:平台管理員 1000(10/50/100/1000)、客戶端 20(10/20) + $isSystemAdmin = Auth::user()->isSystemAdmin(); + $data['maxBatchQuantity'] = $isSystemAdmin ? 1000 : 20; + $data['batchQuickOptions'] = $isSystemAdmin ? [10, 50, 100, 1000] : [10, 20]; + // 1. 通行碼列表 (list) if (!$isAjax || $tab === 'list') { // 僅顯示目前帳號可存取機台的通行碼(機台授權以 machine_user 為準,whereHas 會套用 Machine 的 machine_access 全域 scope) @@ -750,20 +827,79 @@ class SalesController extends Controller */ public function storePassCode(Request $request) { + // 批次數量上限依角色分級:平台管理員最多 1000 筆;客戶端(租戶)最多 20 筆(後端強制把關)。 + $maxQuantity = Auth::user()->isSystemAdmin() ? 1000 : 20; + $validated = $request->validate([ 'machine_id' => 'required|exists:machines,id', 'name' => 'required|string|max:50', 'expires_days' => 'nullable|integer|min:0', - 'custom_code' => 'required|string|min:4|max:12', + 'custom_code' => 'nullable|string|min:4|max:12', + 'quantity' => 'nullable|integer|min:1|max:' . $maxQuantity, ]); $machine = Machine::findOrFail($validated['machine_id']); + $quantity = (int) ($validated['quantity'] ?? 1); $expiresAt = $request->expires_days ? now()->addDays((int) $request->expires_days) : null; + // 批次產生(>1 筆):忽略自訂碼,系統自動產生隨機唯一碼,並以 batch_no 標記同一批。 + if ($quantity > 1) { + $batchNo = 'PSB' . now()->format('YmdHis') . strtoupper(Str::random(4)); + $codes = $this->generateUniqueCodeBatch(PassCode::class, $machine->id, $quantity); + $nowTs = now(); + + $rows = []; + foreach ($codes as $i => $code) { + $rows[] = [ + 'company_id' => $machine->company_id, + 'machine_id' => $machine->id, + 'name' => $validated['name'] . ' #' . ($i + 1), + 'code' => $code, + 'slug' => Str::random(16), + 'batch_no' => $batchNo, + 'expires_at' => $expiresAt, + 'status' => 'active', + 'created_by' => Auth::id(), + 'created_at' => $nowTs, + 'updated_at' => $nowTs, + ]; + } + + DB::transaction(function () use ($rows) { + foreach (array_chunk($rows, 500) as $chunk) { + PassCode::insert($chunk); + } + }); + + SystemOperationLog::create([ + 'company_id' => $machine->company_id, + 'user_id' => Auth::id(), + 'module' => 'pass_code', + 'action' => 'batch_create', + 'target_id' => null, + 'target_type' => PassCode::class, + 'new_values' => [ + 'batch_no' => $batchNo, + 'count' => count($rows), + 'machine_id' => $machine->id, + 'name' => $validated['name'], + ], + ]); + + return back() + ->with('success', __(':count pass codes created', ['count' => count($rows)])) + ->with('code_batch', [ + 'type' => 'pass', + 'batch_no' => $batchNo, + 'count' => count($rows), + 'download_url' => route('admin.sales.pass-codes.batch-download', $batchNo), + ]); + } + $passCode = PassCode::create([ 'machine_id' => $validated['machine_id'], 'name' => $validated['name'] ?? 'Manual Generate', - 'code' => $validated['custom_code'] ?? PassCode::generateUniqueCode($validated['machine_id']), + 'code' => $request->custom_code ?: PassCode::generateUniqueCode($validated['machine_id']), 'expires_at' => $expiresAt, 'status' => 'active', 'company_id' => $machine->company_id, @@ -783,6 +919,125 @@ class SalesController extends Controller return back()->with('success', __('Pass code created: :code', ['code' => $passCode->code])); } + /** + * 批次產生指定數量的唯一 8 位數字碼(避開該機台目前有效的碼,並去除同批重複) + * + * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass PickupCode::class 或 PassCode::class + * @return string[] + */ + private function generateUniqueCodeBatch(string $modelClass, int $machineId, int $quantity): array + { + // 載入該機台目前仍有效的碼,避免與既有碼衝突 + $existing = array_flip( + $modelClass::where('machine_id', $machineId) + ->where('status', 'active') + ->where(function ($q) { + $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); + }) + ->pluck('code') + ->all() + ); + + $codes = []; + $seen = []; + while (count($codes) < $quantity) { + $code = str_pad((string) random_int(0, 99999999), 8, '0', STR_PAD_LEFT); + if (isset($existing[$code]) || isset($seen[$code])) { + continue; + } + $seen[$code] = true; + $codes[] = $code; + } + + return $codes; + } + + /** + * 下載某一批取貨碼清單 (CSV) + */ + public function downloadPickupCodeBatch(string $batchNo) + { + $codes = PickupCode::with(['machine:id,name,serial_no', 'product:id,name']) + ->where('batch_no', $batchNo) + ->orderBy('id') + ->get(); + + abort_if($codes->isEmpty(), 404); + + $header = [ + __('Pickup Code'), + __('Target Machine'), + __('Select Product'), + __('Select Slot'), + __('Usage Limit'), + __('Expires At'), + __('Link'), + __('Created At'), + ]; + + return response()->streamDownload(function () use ($codes, $header) { + $out = fopen('php://output', 'w'); + fwrite($out, "\xEF\xBB\xBF"); // UTF-8 BOM,Excel 開啟才不會亂碼 + fputcsv($out, $header); + foreach ($codes as $c) { + fputcsv($out, [ + $c->code, + optional($c->machine)->name . ' (' . optional($c->machine)->serial_no . ')', + optional($c->product)->name ?? '', + $c->slot_no ?? '', + $c->usage_limit, + optional($c->expires_at)->format('Y-m-d H:i') ?? __('Permanent'), + $c->ticket_url, + optional($c->created_at)->format('Y-m-d H:i'), + ]); + } + fclose($out); + }, 'pickup-codes-' . $batchNo . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + /** + * 下載某一批通行碼清單 (CSV) + */ + public function downloadPassCodeBatch(string $batchNo) + { + $codes = PassCode::with('machine:id,name,serial_no') + ->where('batch_no', $batchNo) + ->orderBy('id') + ->get(); + + abort_if($codes->isEmpty(), 404); + + $header = [ + __('Pass Code'), + __('Description / Name'), + __('Target Machine'), + __('Expires At'), + __('Link'), + __('Created At'), + ]; + + return response()->streamDownload(function () use ($codes, $header) { + $out = fopen('php://output', 'w'); + fwrite($out, "\xEF\xBB\xBF"); + fputcsv($out, $header); + foreach ($codes as $c) { + fputcsv($out, [ + $c->code, + $c->name, + optional($c->machine)->name . ' (' . optional($c->machine)->serial_no . ')', + optional($c->expires_at)->format('Y-m-d H:i') ?? __('Permanent'), + $c->ticket_url, + optional($c->created_at)->format('Y-m-d H:i'), + ]); + } + fclose($out); + }, 'pass-codes-' . $batchNo . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + /** * 更新通行碼 */ diff --git a/app/Http/Controllers/Guest/PickupController.php b/app/Http/Controllers/Guest/PickupController.php index 0909faa..ab44a82 100644 --- a/app/Http/Controllers/Guest/PickupController.php +++ b/app/Http/Controllers/Guest/PickupController.php @@ -13,7 +13,7 @@ class PickupController extends Controller */ public function show($slug) { - $pickupCode = PickupCode::with(['machine.slots.product']) + $pickupCode = PickupCode::with(['machine.slots.product', 'product']) ->where(function($query) use ($slug) { $query->where('slug', $slug) ->orWhere('code', $slug); // 相容舊版或測試 diff --git a/app/Models/Transaction/PassCode.php b/app/Models/Transaction/PassCode.php index c5bd7ff..8803eae 100644 --- a/app/Models/Transaction/PassCode.php +++ b/app/Models/Transaction/PassCode.php @@ -18,6 +18,7 @@ class PassCode extends Model 'name', 'code', 'slug', + 'batch_no', 'expires_at', 'status', 'created_by', diff --git a/app/Models/Transaction/PickupCode.php b/app/Models/Transaction/PickupCode.php index 1e84a75..bdd54b1 100644 --- a/app/Models/Transaction/PickupCode.php +++ b/app/Models/Transaction/PickupCode.php @@ -19,6 +19,7 @@ class PickupCode extends Model 'slot_no', 'code', 'slug', + 'batch_no', 'status', 'expires_at', 'used_at', diff --git a/database/migrations/2026_06_26_130000_create_cache_table.php b/database/migrations/2026_06_26_130000_create_cache_table.php new file mode 100644 index 0000000..45aa5d9 --- /dev/null +++ b/database/migrations/2026_06_26_130000_create_cache_table.php @@ -0,0 +1,38 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration'); + }); + } + + if (!Schema::hasTable('cache_locks')) { + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/2026_06_29_140000_add_batch_no_to_codes_tables.php b/database/migrations/2026_06_29_140000_add_batch_no_to_codes_tables.php new file mode 100644 index 0000000..7376c6f --- /dev/null +++ b/database/migrations/2026_06_29_140000_add_batch_no_to_codes_tables.php @@ -0,0 +1,35 @@ +string('batch_no', 32)->nullable()->after('slug')->index(); + }); + + Schema::table('pass_codes', function (Blueprint $table) { + $table->string('batch_no', 32)->nullable()->after('slug')->index(); + }); + } + + public function down(): void + { + Schema::table('pickup_codes', function (Blueprint $table) { + $table->dropIndex(['batch_no']); + $table->dropColumn('batch_no'); + }); + + Schema::table('pass_codes', function (Blueprint $table) { + $table->dropIndex(['batch_no']); + $table->dropColumn('batch_no'); + }); + } +}; diff --git a/lang/en.json b/lang/en.json index 079f8ba..7c5fa20 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1,4 +1,12 @@ { + "Search Product...": "Search Product...", + "Generate Quantity": "Generate Quantity", + "Batch mode: codes are auto-generated and the custom code is ignored.": "Batch mode: codes are auto-generated and the custom code is ignored.", + "Batch generated successfully": "Batch generated successfully", + "codes": "codes", + "Download List (CSV)": "Download List (CSV)", + ":count pickup codes generated": ":count pickup codes generated", + ":count pass codes created": ":count pass codes created", "(deducted by issued pickup orders)": "(deducted by issued pickup orders)", "-- Select Machine --": "-- Select Machine --", "10s": "10s", diff --git a/lang/ja.json b/lang/ja.json index 6fc3c9c..96221a7 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -1,4 +1,12 @@ { + "Search Product...": "商品を検索...", + "Generate Quantity": "生成数量", + "Batch mode: codes are auto-generated and the custom code is ignored.": "バッチモード:コードは自動生成され、カスタムコードは無視されます。", + "Batch generated successfully": "バッチ生成が完了しました", + "codes": "件", + "Download List (CSV)": "リストをダウンロード (CSV)", + ":count pickup codes generated": ":count 件の受取コードを生成しました", + ":count pass codes created": ":count 件の通行コードを作成しました", "(deducted by issued pickup orders)": "(発行済み受取注文の予約分を差し引き済み)", "-- Select Machine --": "-- 機器を選択 --", "10s": "10秒", diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 93d16c8..54f76e3 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -1,4 +1,12 @@ { + "Search Product...": "搜尋商品...", + "Generate Quantity": "產生數量", + "Batch mode: codes are auto-generated and the custom code is ignored.": "批次模式:系統自動產生隨機碼,將忽略自訂碼。", + "Batch generated successfully": "批次產生成功", + "codes": "筆", + "Download List (CSV)": "下載清單 (CSV)", + ":count pickup codes generated": "已產生 :count 筆取貨碼", + ":count pass codes created": "已建立 :count 筆通行碼", "(deducted by issued pickup orders)": "(已扣除已生成領藥單的預留量)", "-- Select Machine --": "-- 選擇機台 --", "10s": "10秒", diff --git a/resources/views/admin/sales/partials/batch-result-banner.blade.php b/resources/views/admin/sales/partials/batch-result-banner.blade.php new file mode 100644 index 0000000..c154aab --- /dev/null +++ b/resources/views/admin/sales/partials/batch-result-banner.blade.php @@ -0,0 +1,29 @@ +@if(session('code_batch')) + @php($batch = session('code_batch')) +
{{ __('Batch generated successfully') }}
++ {{ __('Batch No') }}: {{ $batch['batch_no'] }} + · {{ $batch['count'] }} {{ __('codes') }} +
++ {{ __('Batch mode: codes are auto-generated and the custom code is ignored.') }} +
++ {{ __('Batch mode: codes are auto-generated and the custom code is ignored.') }} +
+