[PROMOTE] dev -> demo: 現金支付面額明細(收各面額張數+找零)回傳並於銷售紀錄顯示(含 dev 累積:取貨碼/通行碼銷售紀錄等)

This commit is contained in:
twsystem1004 2026-07-03 04:44:17 +00:00
commit 09ebf911ed
13 changed files with 194 additions and 2 deletions

View File

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

View File

@ -60,6 +60,7 @@ class Order extends Model
'discount_amount', 'discount_amount',
'pay_amount', 'pay_amount',
'change_amount', 'change_amount',
'cash_detail',
'points_used', 'points_used',
'original_amount', 'original_amount',
'payment_status', 'payment_status',
@ -82,6 +83,7 @@ class Order extends Model
'discount_amount' => 'decimal:2', 'discount_amount' => 'decimal:2',
'pay_amount' => 'decimal:2', 'pay_amount' => 'decimal:2',
'change_amount' => 'decimal:2', 'change_amount' => 'decimal:2',
'cash_detail' => 'array',
'original_amount' => 'decimal:2', 'original_amount' => 'decimal:2',
'payment_at' => 'datetime', 'payment_at' => 'datetime',
'machine_time' => 'datetime', 'machine_time' => 'datetime',
@ -90,6 +92,42 @@ class Order extends Model
'delivery_status' => 'integer', 'delivery_status' => 'integer',
]; ];
/**
* 現金收款摘要字串:「收:{總收金額}(只列有收到的面額明細) 找:{找零}」。
* 120:1、10:2 30。無 cash_detail非現金或舊資料或全為 0 時回 null
* 總收金額由各面額張數加總得出,與括號內明細一致。
*/
public function getCashReceivedSummaryAttribute(): ?string
{
$cd = $this->cash_detail;
if (empty($cd)) {
return null;
}
// 面額顯示標籤 + 幣值(依大到小)
$denoms = [
'b1000' => ['label' => '仟', 'unit' => 1000],
'b500' => ['label' => '伍佰', 'unit' => 500],
'b100' => ['label' => '佰', 'unit' => 100],
'c50' => ['label' => '50', 'unit' => 50],
'c10' => ['label' => '10', 'unit' => 10],
'c5' => ['label' => '5', 'unit' => 5],
'c1' => ['label' => '1', 'unit' => 1],
];
$received = 0;
$parts = [];
foreach ($denoms as $key => $d) {
$n = (int) ($cd[$key] ?? 0);
if ($n > 0) {
$parts[] = $d['label'] . ':' . $n; // 只列有收到的幣別
$received += $n * $d['unit'];
}
}
if (empty($parts)) {
return null;
}
return '收:' . $received . '' . implode('、', $parts) . ') 找:' . number_format($this->change_amount, 0);
}
/** /**
* 取得所有支付類型的對照標籤 * 取得所有支付類型的對照標籤
*/ */

View File

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

View File

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

View File

@ -71,6 +71,8 @@ class TransactionService
'discount_amount' => $data['discount_amount'] ?? 0, 'discount_amount' => $data['discount_amount'] ?? 0,
'pay_amount' => $data['pay_amount'], 'pay_amount' => $data['pay_amount'],
'change_amount' => $data['change_amount'] ?? 0, 'change_amount' => $data['change_amount'] ?? 0,
// 現金面額明細(僅現金 payment_type=9 會帶;其餘為 null
'cash_detail' => $data['cash_detail'] ?? null,
'points_used' => $data['points_used'] ?? 0, 'points_used' => $data['points_used'] ?? 0,
'original_amount' => $data['original_amount'] ?? $data['total_amount'], 'original_amount' => $data['original_amount'] ?? $data['total_amount'],
'payment_type' => (int) ($data['payment_type'] ?? 0), 'payment_type' => (int) ($data['payment_type'] ?? 0),
@ -124,6 +126,8 @@ class TransactionService
'original_amount' => $data['original_amount'] ?? $order->original_amount, 'original_amount' => $data['original_amount'] ?? $order->original_amount,
'discount_amount' => $data['discount_amount'] ?? $order->discount_amount, 'discount_amount' => $data['discount_amount'] ?? $order->discount_amount,
'change_amount' => $data['change_amount'] ?? $order->change_amount, 'change_amount' => $data['change_amount'] ?? $order->change_amount,
// 現金面額明細completed finalize 才帶pending 階段無投幣結果)
'cash_detail' => $data['cash_detail'] ?? $order->cash_detail,
'points_used' => $data['points_used'] ?? $order->points_used, 'points_used' => $data['points_used'] ?? $order->points_used,
'invoice_info' => $data['invoice_info'] ?? $order->invoice_info, 'invoice_info' => $data['invoice_info'] ?? $order->invoice_info,
'delivery_status' => isset($data['delivery_status']) 'delivery_status' => isset($data['delivery_status'])
@ -158,6 +162,8 @@ class TransactionService
$order->items()->create([ $order->items()->create([
'product_id' => $item['product_id'], 'product_id' => $item['product_id'],
'product_name' => $productName, 'product_name' => $productName,
// 消費者選擇的貨道/格子號App items[].slot_no 帶入);不管出貨成功或失敗都記錄。
'slot_no' => $item['slot_no'] ?? null,
'barcode' => $item['barcode'] ?? null, 'barcode' => $item['barcode'] ?? null,
'price' => $item['price'], 'price' => $item['price'],
'quantity' => $item['quantity'], 'quantity' => $item['quantity'],
@ -460,6 +466,7 @@ class TransactionService
} }
// 3. Record Dispense Results (B602) - Optional/Multiple // 3. Record Dispense Results (B602) - Optional/Multiple
$dispensedSlots = []; // 收集實際出貨貨道/櫃號,供取貨碼核銷日誌記錄「真正取貨的貨道」
if (!empty($data['dispense'])) { if (!empty($data['dispense'])) {
$dispenseList = isset($data['dispense'][0]) ? $data['dispense'] : [$data['dispense']]; $dispenseList = isset($data['dispense'][0]) ? $data['dispense'] : [$data['dispense']];
foreach ($dispenseList as $dispenseItem) { foreach ($dispenseList as $dispenseItem) {
@ -472,6 +479,10 @@ class TransactionService
$dispenseItem['member_barcode'] = $order->member_barcode; $dispenseItem['member_barcode'] = $order->member_barcode;
} }
if (!empty($dispenseItem['slot_no'])) {
$dispensedSlots[] = (string) $dispenseItem['slot_no'];
}
$this->recordDispense($dispenseItem); $this->recordDispense($dispenseItem);
} }
} }
@ -523,7 +534,11 @@ class TransactionService
'action' => $isUsedUp ? 'used' : 'consume', 'action' => $isUsedUp ? 'used' : 'consume',
'remark' => "MQTT 交易完成自動核銷 (" . ($newCount) . "/" . $pickupCode->usage_limit . "),訂單: #{$order->id}", 'remark' => "MQTT 交易完成自動核銷 (" . ($newCount) . "/" . $pickupCode->usage_limit . "),訂單: #{$order->id}",
'raw_data' => [ 'raw_data' => [
'slot' => $pickupCode->slot_no, // 取貨碼綁「商品」時 pickupCode->slot_no 為 null
// 改記「實際出貨的貨道/櫃號」(dispense),取不到才退回取貨碼綁定貨道。
'slot' => !empty($dispensedSlots)
? implode(', ', array_unique($dispensedSlots))
: $pickupCode->slot_no,
'code' => $pickupCode->code, 'code' => $pickupCode->code,
'count' => $newCount, 'count' => $newCount,
'limit' => $pickupCode->usage_limit 'limit' => $pickupCode->usage_limit
@ -533,6 +548,8 @@ class TransactionService
break; break;
case 5: // 通行碼 (Pass Code) case 5: // 通行碼 (Pass Code)
// 注意:通行碼「用掉一次」的計數改在 B670 驗證(verifyPassCode)當下處理,
// 因為 App 對通行碼會略過交易結案上報,此 case 5 實務上不會被觸發。
// 建立通行碼核銷日誌 // 建立通行碼核銷日誌
PassCodeLog::create([ PassCodeLog::create([
'company_id' => $order->company_id, '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

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 現金支付面額明細orders 增加 cash_detail JSON 欄位。
* 記錄本筆現金交易「收」進的各面額張數(仟/伍佰//50/10/5/1
* 供銷售紀錄在支付金額下方顯示「收:仟:x、伍佰:x… :x」。
* 找零金額沿用既有 change_amount不另存。
* 僅現金(payment_type=9)交易會帶此欄,其餘交易為 null,不影響既有行為。
*/
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
if (!Schema::hasColumn('orders', 'cash_detail')) {
$table->json('cash_detail')->nullable()
->comment('現金收款面額明細 {b1000,b500,b100,c50,c10,c5,c1}')
->after('change_amount');
}
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
if (Schema::hasColumn('orders', 'cash_detail')) {
$table->dropColumn('cash_detail');
}
});
}
};

View File

@ -1,4 +1,5 @@
{ {
"Single use only": "只能使用一次",
"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.": "批次模式:系統自動產生隨機碼,將忽略自訂碼。",
@ -1806,7 +1807,7 @@
"Remote Change": "遠端找零", "Remote Change": "遠端找零",
"Remote Checkout": "遠端結帳", "Remote Checkout": "遠端結帳",
"Remote Command Center": "遠端指令中心", "Remote Command Center": "遠端指令中心",
"Remote Dispense": "遠端出貨", "Remote Dispense": "遠端開櫃",
"Remote Lock": "遠端鎖定", "Remote Lock": "遠端鎖定",
"Remote Management": "遠端管理", "Remote Management": "遠端管理",
"Remote Permissions": "遠端管理權限", "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> <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"> <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> <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> </div>
<div class="text-right"> <div class="text-right">

View File

@ -285,6 +285,18 @@
{{ __('Discounted') }} ${{ number_format($order->discount_amount, 0) }} {{ __('Discounted') }} ${{ number_format($order->discount_amount, 0) }}
</span> </span>
@endif @endif
@if($order->cash_received_summary)
<span class="text-[10px] text-emerald-600 dark:text-emerald-400 font-bold mt-1 tracking-tight leading-relaxed">
{{ $order->cash_received_summary }}
</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) @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"> <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"> <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
@ -461,6 +473,11 @@
<span class="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800/50 text-slate-500">{{ <span class="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800/50 text-slate-500">{{
$paymentTypes[$order->payment_type] ?? '??' }}</span> $paymentTypes[$order->payment_type] ?? '??' }}</span>
</p> </p>
@if($order->cash_received_summary)
<div class="text-[10px] text-emerald-600 dark:text-emerald-400 font-bold mt-1 leading-relaxed">
{{ $order->cash_received_summary }}
</div>
@endif
@if($order->payment_type == 41 && $order->staffCardLog && $order->staffCardLog->staffCard) @if($order->payment_type == 41 && $order->staffCardLog && $order->staffCardLog->staffCard)
<div class="text-[10px] text-cyan-600 dark:text-cyan-400 font-extrabold mt-1 flex items-center gap-1"> <div class="text-[10px] text-cyan-600 dark:text-cyan-400 font-extrabold mt-1 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"> <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>
</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"> <div class="flex justify-end gap-x-4 pt-8">
<button type="button" @click="showCreateModal = false" class="btn-luxury-ghost px-8"> <button type="button" @click="showCreateModal = false" class="btn-luxury-ghost px-8">
{{ __('Cancel') }} {{ __('Cancel') }}