feat(sales): 批次產碼數量上限依角色分級(客戶端最多20、管理員最多1000)

- 平台管理員(isSystemAdmin):數量上限 1000,快捷 10/50/100/1000
- 客戶端(租戶):數量上限 20,快捷 10/20,欄位輸入超過自動夾回 20
- 後端 store 以角色動態 max 驗證,防止前端被繞過送出大量產碼
- 取貨碼/通行碼兩表單同步;前端 max 與快捷選項由後端依角色下發

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
terrylee 2026-06-29 14:24:47 +08:00
parent 9dbde50f56
commit 179348ad66
3 changed files with 34 additions and 11 deletions

View File

@ -374,6 +374,11 @@ class SalesController extends Controller
'tab' => $tab, 'tab' => $tab,
]; ];
// 批次產生數量上限/快捷選項依角色分級:平台管理員 100010/50/100/1000、客戶端 2010/20
$isSystemAdmin = Auth::user()->isSystemAdmin();
$data['maxBatchQuantity'] = $isSystemAdmin ? 1000 : 20;
$data['batchQuickOptions'] = $isSystemAdmin ? [10, 50, 100, 1000] : [10, 20];
// 1. 取貨碼列表 (list) // 1. 取貨碼列表 (list)
if (!$isAjax || $tab === 'list') { if (!$isAjax || $tab === 'list') {
// 僅顯示目前帳號可存取機台的取貨碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope // 僅顯示目前帳號可存取機台的取貨碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
@ -475,6 +480,10 @@ class SalesController extends Controller
*/ */
public function storePickupCode(Request $request) public function storePickupCode(Request $request)
{ {
// 批次數量上限依角色分級:平台管理員最多 1000 筆;客戶端(租戶)最多 20 筆。
// 後端強制把關,避免前端被繞過送出大量產碼請求。
$maxQuantity = Auth::user()->isSystemAdmin() ? 1000 : 20;
$validated = $request->validate([ $validated = $request->validate([
'machine_id' => 'required|exists:machines,id', 'machine_id' => 'required|exists:machines,id',
// 取貨碼改綁「商品」:新流程傳 product_id舊的綁「貨道」流程仍可傳 slot_no向後相容 // 取貨碼改綁「商品」:新流程傳 product_id舊的綁「貨道」流程仍可傳 slot_no向後相容
@ -484,7 +493,7 @@ class SalesController extends Controller
'expires_hours' => 'nullable|integer|min:1|max:720', 'expires_hours' => 'nullable|integer|min:1|max:720',
'expires_at' => 'nullable|date|after:now', 'expires_at' => 'nullable|date|after:now',
'custom_code' => 'nullable|string|min:4|max:12', 'custom_code' => 'nullable|string|min:4|max:12',
'quantity' => 'nullable|integer|min:1|max:1000', 'quantity' => 'nullable|integer|min:1|max:' . $maxQuantity,
]); ]);
$machine = Machine::findOrFail($validated['machine_id']); $machine = Machine::findOrFail($validated['machine_id']);
@ -704,6 +713,11 @@ class SalesController extends Controller
'tab' => $tab, 'tab' => $tab,
]; ];
// 批次產生數量上限/快捷選項依角色分級:平台管理員 100010/50/100/1000、客戶端 2010/20
$isSystemAdmin = Auth::user()->isSystemAdmin();
$data['maxBatchQuantity'] = $isSystemAdmin ? 1000 : 20;
$data['batchQuickOptions'] = $isSystemAdmin ? [10, 50, 100, 1000] : [10, 20];
// 1. 通行碼列表 (list) // 1. 通行碼列表 (list)
if (!$isAjax || $tab === 'list') { if (!$isAjax || $tab === 'list') {
// 僅顯示目前帳號可存取機台的通行碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope // 僅顯示目前帳號可存取機台的通行碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
@ -813,12 +827,15 @@ class SalesController extends Controller
*/ */
public function storePassCode(Request $request) public function storePassCode(Request $request)
{ {
// 批次數量上限依角色分級:平台管理員最多 1000 筆;客戶端(租戶)最多 20 筆(後端強制把關)。
$maxQuantity = Auth::user()->isSystemAdmin() ? 1000 : 20;
$validated = $request->validate([ $validated = $request->validate([
'machine_id' => 'required|exists:machines,id', 'machine_id' => 'required|exists:machines,id',
'name' => 'required|string|max:50', 'name' => 'required|string|max:50',
'expires_days' => 'nullable|integer|min:0', 'expires_days' => 'nullable|integer|min:0',
'custom_code' => 'nullable|string|min:4|max:12', 'custom_code' => 'nullable|string|min:4|max:12',
'quantity' => 'nullable|integer|min:1|max:1000', 'quantity' => 'nullable|integer|min:1|max:' . $maxQuantity,
]); ]);
$machine = Machine::findOrFail($validated['machine_id']); $machine = Machine::findOrFail($validated['machine_id']);

View File

@ -13,6 +13,8 @@
expiresDays: 7, expiresDays: 7,
customCode: '', customCode: '',
quantity: 1, quantity: 1,
maxBatchQty: {{ (int) ($maxBatchQuantity ?? 20) }},
batchQuickOptions: @js($batchQuickOptions ?? [10, 20]),
status: '{{ request('status', '') }}', status: '{{ request('status', '') }}',
showQrModal: false, showQrModal: false,
activeQrCode: '', activeQrCode: '',
@ -309,7 +311,7 @@
{{-- Quantity (Batch Generate) --}} {{-- Quantity (Batch Generate) --}}
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1"> <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Generate Quantity') }} (1-1000) {{ __('Generate Quantity') }} (1-{{ $maxBatchQuantity ?? 20 }})
</label> </label>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all"> <div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
@ -318,16 +320,17 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button> </button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div> <div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="number" name="quantity" x-model="quantity" min="1" max="1000" <input type="number" name="quantity" x-model="quantity" min="1" :max="maxBatchQty"
@input="if (parseInt(quantity) > maxBatchQty) quantity = maxBatchQty"
class="flex-1 min-w-0 bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"> class="flex-1 min-w-0 bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div> <div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="quantity = Math.min(1000, parseInt(quantity || 1) + 1)" <button type="button" @click="quantity = Math.min(maxBatchQty, parseInt(quantity || 1) + 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all"> class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button> </button>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<template x-for="val in [10, 50, 100, 1000]"> <template x-for="val in batchQuickOptions" :key="val">
<button type="button" @click="quantity = val" <button type="button" @click="quantity = val"
class="px-2 h-10 rounded-xl border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[11px] font-black transition-all shrink-0" class="px-2 h-10 rounded-xl border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[11px] font-black transition-all shrink-0"
:class="quantity == val ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'"> :class="quantity == val ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">

View File

@ -295,10 +295,10 @@ transition-all duration-300",
</div> </div>
</div> </div>
{{-- Quantity (Batch Generate) 獨立整列,避免與使用次數上限擠在同一列重疊 --}} {{-- Quantity (Batch Generate) 獨立整列,避免與使用次數上限擠在同一列重疊。上限/快捷選項依角色分級 --}}
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Generate Quantity') }} (1-1000)</label> __('Generate Quantity') }} (1-{{ $maxBatchQuantity ?? 20 }})</label>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all"> <div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="quantity = Math.max(1, parseInt(quantity || 1) - 1)" <button type="button" @click="quantity = Math.max(1, parseInt(quantity || 1) - 1)"
@ -306,16 +306,17 @@ transition-all duration-300",
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button> </button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div> <div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="number" name="quantity" x-model="quantity" min="1" max="1000" <input type="number" name="quantity" x-model="quantity" min="1" :max="maxBatchQty"
@input="if (parseInt(quantity) > maxBatchQty) quantity = maxBatchQty"
class="flex-1 min-w-0 bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"> class="flex-1 min-w-0 bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div> <div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="quantity = Math.min(1000, parseInt(quantity || 1) + 1)" <button type="button" @click="quantity = Math.min(maxBatchQty, parseInt(quantity || 1) + 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all"> class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button> </button>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<template x-for="val in [10, 50, 100, 1000]"> <template x-for="val in batchQuickOptions" :key="val">
<button type="button" @click="quantity = val" <button type="button" @click="quantity = val"
class="px-2 h-10 rounded-xl border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[11px] font-black transition-all shrink-0" class="px-2 h-10 rounded-xl border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[11px] font-black transition-all shrink-0"
:class="quantity == val ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'"> :class="quantity == val ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
@ -541,6 +542,8 @@ transition-all duration-300",
customExpiry: '', customExpiry: '',
usageLimit: 1, usageLimit: 1,
quantity: 1, quantity: 1,
maxBatchQty: {{ (int) ($maxBatchQuantity ?? 20) }},
batchQuickOptions: @js($batchQuickOptions ?? [10, 20]),
showQrModal: false, showQrModal: false,
activeQrCode: '', activeQrCode: '',
activeTicketUrl: '', activeTicketUrl: '',