取貨碼/通行碼/銷售紀錄 後台功能

- 取貨碼使用紀錄記實際出貨貨道(PickupCodeLog.raw_data.slot)
- 銷售紀錄訂單列(支付金額欄)顯示取貨碼序號(member_barcode)
- 遠端指令中心「遠端出貨」→「遠端開櫃」(zh_TW.json Remote Dispense)
- 訂單品項記消費者選的貨道(order_items.slot_no + migration),失敗交易也看得到;
  訂單明細顯示 Slot;OrderItem/TransactionService 對應
- 通行碼「只能使用一次」:pass_codes.usage_limit/usage_count + migration;
  建立表單加勾選;B670(verifyPassCode)驗證時消耗、達上限標 used;PassCode.isValid 判斷

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
terrylee 2026-07-02 03:11:24 +00:00
parent ce9e52efd9
commit dc9421e482
11 changed files with 107 additions and 2 deletions

View File

@ -841,6 +841,8 @@ class SalesController extends Controller
$machine = Machine::findOrFail($validated['machine_id']);
$quantity = (int) ($validated['quantity'] ?? 1);
$expiresAt = $request->expires_days ? now()->addDays((int) $request->expires_days) : null;
// 勾「只能使用一次」→ 使用上限 1未勾 → null(無限次,維持原行為)。
$usageLimit = $request->boolean('single_use') ? 1 : null;
// 批次產生(>1 筆):忽略自訂碼,系統自動產生隨機唯一碼,並以 batch_no 標記同一批。
if ($quantity > 1) {
@ -859,6 +861,8 @@ class SalesController extends Controller
'batch_no' => $batchNo,
'expires_at' => $expiresAt,
'status' => 'active',
'usage_limit' => $usageLimit,
'usage_count' => 0,
'created_by' => Auth::id(),
'created_at' => $nowTs,
'updated_at' => $nowTs,
@ -902,6 +906,8 @@ class SalesController extends Controller
'code' => $request->custom_code ?: PassCode::generateUniqueCode($validated['machine_id']),
'expires_at' => $expiresAt,
'status' => 'active',
'usage_limit' => $usageLimit,
'usage_count' => 0,
'company_id' => $machine->company_id,
'created_by' => Auth::id(),
]);

View File

@ -852,6 +852,17 @@ class MachineController extends Controller
'raw_data' => ['machine_sn' => $machine->serial_no, 'code' => $passCode->code]
]);
// 單次/限次通行碼:驗證成功即計為使用一次;達上限就標記 used
// 下次驗證會被上方 where('status','active') 過濾擋掉,達成「只能使用一次」。
// usage_limit 為 null未勾單次時不計數維持原本無限次行為。
if (!is_null($passCode->usage_limit)) {
$newCount = $passCode->usage_count + 1;
$passCode->update([
'usage_count' => $newCount,
'status' => $newCount >= $passCode->usage_limit ? 'used' : $passCode->status,
]);
}
return response()->json([
'success' => true,

View File

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

View File

@ -21,6 +21,8 @@ class PassCode extends Model
'batch_no',
'expires_at',
'status',
'usage_limit', // 可用次數上限null=無限次。勾「只能使用一次」時=1
'usage_count', // 已使用次數
'created_by',
];
@ -77,6 +79,11 @@ class PassCode extends Model
return false;
}
// 有設使用次數上限(如「只能使用一次」usage_limit=1)且已用完 → 失效。null=無限次。
if (!is_null($this->usage_limit) && $this->usage_count >= $this->usage_limit) {
return false;
}
return true;
}

View File

@ -158,6 +158,8 @@ class TransactionService
$order->items()->create([
'product_id' => $item['product_id'],
'product_name' => $productName,
// 消費者選擇的貨道/格子號App items[].slot_no 帶入);不管出貨成功或失敗都記錄。
'slot_no' => $item['slot_no'] ?? null,
'barcode' => $item['barcode'] ?? null,
'price' => $item['price'],
'quantity' => $item['quantity'],
@ -460,6 +462,7 @@ class TransactionService
}
// 3. Record Dispense Results (B602) - Optional/Multiple
$dispensedSlots = []; // 收集實際出貨貨道/櫃號,供取貨碼核銷日誌記錄「真正取貨的貨道」
if (!empty($data['dispense'])) {
$dispenseList = isset($data['dispense'][0]) ? $data['dispense'] : [$data['dispense']];
foreach ($dispenseList as $dispenseItem) {
@ -472,6 +475,10 @@ class TransactionService
$dispenseItem['member_barcode'] = $order->member_barcode;
}
if (!empty($dispenseItem['slot_no'])) {
$dispensedSlots[] = (string) $dispenseItem['slot_no'];
}
$this->recordDispense($dispenseItem);
}
}
@ -523,7 +530,11 @@ class TransactionService
'action' => $isUsedUp ? 'used' : 'consume',
'remark' => "MQTT 交易完成自動核銷 (" . ($newCount) . "/" . $pickupCode->usage_limit . "),訂單: #{$order->id}",
'raw_data' => [
'slot' => $pickupCode->slot_no,
// 取貨碼綁「商品」時 pickupCode->slot_no 為 null
// 改記「實際出貨的貨道/櫃號」(dispense),取不到才退回取貨碼綁定貨道。
'slot' => !empty($dispensedSlots)
? implode(', ', array_unique($dispensedSlots))
: $pickupCode->slot_no,
'code' => $pickupCode->code,
'count' => $newCount,
'limit' => $pickupCode->usage_limit
@ -533,6 +544,8 @@ class TransactionService
break;
case 5: // 通行碼 (Pass Code)
// 注意:通行碼「用掉一次」的計數改在 B670 驗證(verifyPassCode)當下處理,
// 因為 App 對通行碼會略過交易結案上報,此 case 5 實務上不會被觸發。
// 建立通行碼核銷日誌
PassCodeLog::create([
'company_id' => $order->company_id,

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('order_items', function (Blueprint $table) {
// 消費者選擇的貨道/格子號(格子櫃=櫃號如 501VMC=貨道號)。
// 由 App transaction finalize 的 items[].slot_no 帶入;不管出貨成功或失敗都記錄。
$table->string('slot_no', 20)->nullable()->after('product_name');
});
}
public function down(): void
{
Schema::table('order_items', function (Blueprint $table) {
$table->dropColumn('slot_no');
});
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('pass_codes', function (Blueprint $table) {
// 可用次數上限null=無限次(維持原行為)。勾「只能使用一次」時=1。
$table->unsignedInteger('usage_limit')->nullable()->after('status');
$table->unsignedInteger('usage_count')->default(0)->after('usage_limit');
});
}
public function down(): void
{
Schema::table('pass_codes', function (Blueprint $table) {
$table->dropColumn(['usage_limit', 'usage_count']);
});
}
};

View File

@ -1,4 +1,5 @@
{
"Single use only": "只能使用一次",
"Search Product...": "搜尋商品...",
"Generate Quantity": "產生數量",
"Batch mode: codes are auto-generated and the custom code is ignored.": "批次模式:系統自動產生隨機碼,將忽略自訂碼。",
@ -1806,7 +1807,7 @@
"Remote Change": "遠端找零",
"Remote Checkout": "遠端結帳",
"Remote Command Center": "遠端指令中心",
"Remote Dispense": "遠端出貨",
"Remote Dispense": "遠端開櫃",
"Remote Lock": "遠端鎖定",
"Remote Management": "遠端管理",
"Remote Permissions": "遠端管理權限",

View File

@ -233,6 +233,10 @@
<p class="text-base font-extrabold text-slate-800 dark:text-white truncate tracking-tight">{{ $item->product_name }}</p>
<div class="flex items-center gap-3 mt-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">${{ number_format($item->price, 0) }} × {{ $item->quantity }}</span>
@if(!empty($item->slot_no))
{{-- 消費者選擇的貨道/格子(不管出貨成功失敗都會記錄) --}}
<span class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-widest">{{ __('Slot') }}: {{ $item->slot_no }}</span>
@endif
</div>
</div>
<div class="text-right">

View File

@ -285,6 +285,13 @@
{{ __('Discounted') }} ${{ number_format($order->discount_amount, 0) }}
</span>
@endif
@if((int) $order->payment_type === 6 && !empty($order->member_barcode))
{{-- 取貨碼交易:於支付金額欄換行加註取貨碼序號 --}}
<span class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 mt-1.5 flex items-center gap-1">
{{ __('Pickup Code') }}:
<span class="font-mono tracking-tighter normal-case text-slate-700 dark:text-slate-200">{{ $order->member_barcode }}</span>
</span>
@endif
@if($order->payment_type == 41 && $order->staffCardLog && $order->staffCardLog->staffCard)
<span class="text-[10px] text-cyan-600 dark:text-cyan-400 font-extrabold mt-1.5 flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">

View File

@ -421,6 +421,13 @@
</div>
</div>
{{-- 只能使用一次:勾選 usage_limit=1;不勾 無限次(維持原行為) --}}
<label class="flex items-center gap-3 px-1 cursor-pointer select-none">
<input type="checkbox" name="single_use" value="1"
class="w-5 h-5 rounded border-slate-300 dark:border-slate-600 text-cyan-500 focus:ring-cyan-500 cursor-pointer">
<span class="text-xs font-black text-slate-500 uppercase tracking-widest">{{ __('Single use only') }}</span>
</label>
<div class="flex justify-end gap-x-4 pt-8">
<button type="button" @click="showCreateModal = false" class="btn-luxury-ghost px-8">
{{ __('Cancel') }}