[PROMOTE] dev -> demo: 取貨碼改綁商品

1. [FEAT] 取貨碼改綁「商品」而非「貨道」:發碼時指定機台+商品,刷碼當下由 App 依即時庫存挑貨道。
2. 新增 pickup_codes.product_id 欄位 migration(nullable + index,向後相容綁貨道碼)。
3. 產碼端點驗證商品屬機台所屬公司,新增公司商品清單 AJAX 與「機台未上架」前端提示。
4. B660 刷碼新增綁商品擋關(無貨道回 409 並寫 log)。
5. 多語系三語系同步新增 4 組詞彙。

⚠️ 部署後需執行 migrate 建立 product_id 欄位。
This commit is contained in:
sky121113 2026-06-26 17:32:45 +08:00
commit 1404d40f71
9 changed files with 171 additions and 35 deletions

View File

@ -476,23 +476,37 @@ class SalesController extends Controller
{
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'slot_no' => 'required|string',
// 取貨碼改綁「商品」:新流程傳 product_id舊的綁「貨道」流程仍可傳 slot_no向後相容
'product_id' => 'required_without:slot_no|nullable|exists:products,id',
'slot_no' => 'required_without:product_id|nullable|string',
'usage_limit' => 'nullable|integer|min:1|max:20',
'expires_hours' => 'nullable|integer|min:1|max:720',
'expires_hours' => 'nullable|integer|min:1|max:720',
'expires_at' => 'nullable|date|after:now',
'custom_code' => 'nullable|string|min:4|max:12',
]);
$machine = Machine::findOrFail($validated['machine_id']);
// 綁商品時驗證該商品屬於此機台所屬公司(不要求機台當下有貨道;機台未上架仍可產碼,
// 由前端提示、刷碼時若無貨道再由 B660 擋下)。
if (!empty($validated['product_id'])) {
$belongsToCompany = \App\Models\Product\Product::where('id', $validated['product_id'])
->where('company_id', $machine->company_id)
->exists();
if (!$belongsToCompany) {
return back()->withErrors(['product_id' => __('The selected product does not belong to this machine\'s company.')]);
}
}
// 處理過期時間:優先使用直接傳入的日期,否則使用時數計算
$expiresAt = $request->filled('expires_at')
? Carbon::parse($validated['expires_at'])
$expiresAt = $request->filled('expires_at')
? Carbon::parse($validated['expires_at'])
: now()->addHours((int) ($request->expires_hours ?? 24));
$pickupCode = PickupCode::create([
'machine_id' => $validated['machine_id'],
'slot_no' => $validated['slot_no'],
'product_id' => $validated['product_id'] ?? null,
'slot_no' => $validated['slot_no'] ?? null,
'code' => $request->custom_code ?? PickupCode::generateUniqueCode($validated['machine_id']),
'usage_limit' => $validated['usage_limit'] ?? 1,
'usage_count' => 0,
@ -515,6 +529,30 @@ class SalesController extends Controller
return back()->with('success', __('Pickup code generated: :code', ['code' => $pickupCode->code]));
}
/**
* 產碼用 AJAX回傳機台所屬「公司」的商品清單以及機台目前已上架的 product_id。
* 商品下拉以公司商品為準(非僅機台貨道);前端用 machine_product_ids 標示「此機台未上架」提示。
*/
public function pickupCompanyProducts(Machine $machine)
{
$products = \App\Models\Product\Product::where('company_id', $machine->company_id)
->select('id', 'name')
->orderBy('name')
->get();
$machineProductIds = $machine->slots()
->whereNotNull('product_id')
->pluck('product_id')
->unique()
->values();
return response()->json([
'success' => true,
'products' => $products,
'machine_product_ids' => $machineProductIds,
]);
}
/**
* 更新取貨碼 (僅限修改時間)
*/

View File

@ -701,9 +701,10 @@ class MachineController extends Controller
// 一般取貨碼的「核銷」仍由 MQTT finalizeTransaction 流程處理(行為不變)。
$data = [
'slot_no' => $pickupCode->slot_no, // 保留:既有單貨道機台讀此欄(領藥單為 null
'slot_no' => $pickupCode->slot_no, // 保留:既有綁貨道碼讀此欄(綁商品碼/領藥單為 null
'product_id' => $pickupCode->product_id, // 綁商品碼App 據此自挑可出貨道(綁貨道碼為 null
'pickup_code_id' => $pickupCode->id,
'code_id' => $pickupCode->id, // 統一回傳 code_id
'code_id' => $pickupCode->id, // 統一回傳 code_id
'status' => $pickupCode->status,
];
@ -780,6 +781,27 @@ class MachineController extends Controller
$data['status'] = $pickupCode->status;
}
// === 綁商品取貨碼(非領藥單)擋關:機台須仍有此商品貨道,否則 App 必然挑不到貨道 ===
// 即時庫存/鎖定/效期由 App 刷碼當下自行判斷;此處只擋「機台根本沒上這商品」的明確錯誤。
if (!$isPharmacy && $pickupCode->product_id) {
$hasProduct = $machine->slots()->where('product_id', $pickupCode->product_id)->exists();
if (!$hasProduct) {
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'action' => 'verify_failed',
'remark' => 'log.pickup.no_slot_for_product',
'raw_data' => ['code' => $pickupCode->code, 'product_id' => $pickupCode->product_id],
]);
return response()->json([
'success' => false,
'code' => 409,
'message' => '此機台目前無此商品貨道,無法取貨',
], 409);
}
}
return response()->json([
'success' => true,
'code' => 200,

View File

@ -15,6 +15,7 @@ class PickupCode extends Model
protected $fillable = [
'company_id',
'machine_id',
'product_id',
'slot_no',
'code',
'slug',
@ -58,6 +59,14 @@ class PickupCode extends Model
return $this->belongsTo(Machine::class);
}
/**
* 關聯商品(取貨碼改綁商品時使用)
*/
public function product(): BelongsTo
{
return $this->belongsTo(\App\Models\Product\Product::class);
}
/**
* 關聯建立者
*/

View File

@ -0,0 +1,28 @@
<?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('pickup_codes', function (Blueprint $table) {
// 取貨碼改綁「商品」而非「貨道」:發碼時只指定機台+商品,
// 刷碼當下由 App 依本機即時庫存/鎖定/效期挑可出貨道slot_no 對綁商品碼為 null
// 既有綁貨道碼slot_no 有值、product_id 為 null行為不變向後相容。
$table->unsignedBigInteger('product_id')->nullable()->after('machine_id')
->comment('綁定商品(取貨碼改綁商品時使用;貨道由 App 刷碼當下挑)');
$table->index('product_id');
});
}
public function down(): void
{
Schema::table('pickup_codes', function (Blueprint $table) {
$table->dropIndex(['product_id']);
$table->dropColumn('product_id');
});
}
};

View File

@ -1422,6 +1422,7 @@
"Not Used": "Not Used",
"Not Used Description": "Not Used Description",
"Not a co-branded card": "Not a co-branded card",
"Not stocked on this machine": "Not stocked on this machine",
"Note": "Note",
"Note (optional)": "Note (optional)",
"Notes": "Notes",
@ -1649,6 +1650,7 @@
"Please select a machine to view and manage its advertisements.": "Please select a machine to view and manage its advertisements.",
"Please select a machine to view metrics": "Please select a machine to view metrics",
"Please select a material": "Please select a material",
"Please select a product": "Please select a product",
"Please select a slot": "Please select a slot",
"Please select an APK file to upload": "Please select an APK file to upload",
"Please select at least one medicine with quantity.": "Please select at least one medicine with quantity.",
@ -2216,6 +2218,7 @@
"The image is too large. Please upload an image smaller than 1MB.": "The image is too large. Please upload an image smaller than 1MB.",
"The image is too large. Please upload an image smaller than 5MB.": "The image is too large. Please upload an image smaller than 5MB.",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "The machine will be notified to fetch the latest system settings. It may take a moment to apply.",
"The selected product does not belong to this machine's company.": "The selected product does not belong to this machine's company.",
"This Month": "This Month",
"This Week": "This Week",
"This account does not belong to this company.": "This account does not belong to this company.",
@ -2226,6 +2229,7 @@
"This machine has e-invoice disabled; cannot issue": "This machine has e-invoice disabled; cannot issue",
"This machine has no ECPay invoice settings": "This machine has no ECPay invoice settings",
"This pharmacy pickup order can no longer be cancelled.": "This pharmacy pickup order can no longer be cancelled.",
"This product is not stocked on the selected machine; the code may not be redeemable until restocked.": "This product is not stocked on the selected machine; the code may not be redeemable until restocked.",
"This role belongs to another company and cannot be assigned.": "This role belongs to another company and cannot be assigned.",
"This slot has a pending command. Please wait.": "This slot has a pending command. Please wait.",
"This slot has a pending update. Please wait for the previous command to complete.": "This slot has a pending update. Please wait for the previous command to complete.",

View File

@ -1422,6 +1422,7 @@
"Not Used": "使用しない",
"Not Used Description": "サードパーティ決済を使用しない",
"Not a co-branded card": "提携カードではありません",
"Not stocked on this machine": "この機器に未陳列",
"Note": "備考",
"Note (optional)": "備考 (任意)",
"Notes": "備考",
@ -1649,6 +1650,7 @@
"Please select a machine to view and manage its advertisements.": "機器を選択して、その広告を表示および管理してください。",
"Please select a machine to view metrics": "指標を表示する機器を選択してください",
"Please select a material": "素材を選択してください",
"Please select a product": "商品を選択してください",
"Please select a slot": "スロットを選択してください",
"Please select an APK file to upload": "Please select an APK file to upload",
"Please select at least one medicine with quantity.": "数量を入力した薬品を少なくとも1つ選択してください。",
@ -2216,6 +2218,7 @@
"The image is too large. Please upload an image smaller than 1MB.": "画像サイズが大きすぎます。1MB未満の画像をアップロードしてください。",
"The image is too large. Please upload an image smaller than 5MB.": "画像サイズが大きすぎます。5MB未満の画像をアップロードしてください。",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "最新のシステム設定を取得するよう機台に通知しました。反映まで少々時間がかかる場合があります。",
"The selected product does not belong to this machine's company.": "選択した商品はこの機器の所属会社に属していません。",
"This Month": "今月",
"This Week": "今週",
"This account does not belong to this company.": "このアカウントは該当企業に所属していません。",
@ -2226,6 +2229,7 @@
"This machine has e-invoice disabled; cannot issue": "この機台は電子インボイスが無効のため発行できません",
"This machine has no ECPay invoice settings": "This machine has no ECPay invoice settings",
"This pharmacy pickup order can no longer be cancelled.": "この薬品受取注文はこれ以上取り消せません。",
"This product is not stocked on the selected machine; the code may not be redeemable until restocked.": "この商品は選択した機器に陳列されていません。補充されるまでコードを引き換えできない場合があります。",
"This role belongs to another company and cannot be assigned.": "このロールは別の会社に属しているため、割り当てることができません。",
"This slot has a pending command. Please wait.": "このスロットには実行中のコマンドがあります。しばらくお待ちください。",
"This slot has a pending update. Please wait for the previous command to complete.": "このスロットには保留中の更新があります。前のコマンドが完了するまでお待ちください。",

View File

@ -1422,6 +1422,7 @@
"Not Used": "不使用",
"Not Used Description": "不使用第三方支付介接",
"Not a co-branded card": "非聯名卡",
"Not stocked on this machine": "未在此機台上架",
"Note": "備註",
"Note (optional)": "備註 (選填)",
"Notes": "備註",
@ -1649,6 +1650,7 @@
"Please select a machine to view and manage its advertisements.": "請選擇一個機台以查看並管理其廣告。",
"Please select a machine to view metrics": "請選擇機台以查看數據",
"Please select a material": "請選擇素材",
"Please select a product": "請選擇商品",
"Please select a slot": "請選擇貨道",
"Please select an APK file to upload": "請選擇要上傳的 APK 檔案",
"Please select at least one medicine with quantity.": "請至少勾選一項藥品並填寫數量。",
@ -2216,6 +2218,7 @@
"The image is too large. Please upload an image smaller than 1MB.": "圖片檔案太大,請上傳小於 1MB 的圖片。",
"The image is too large. Please upload an image smaller than 5MB.": "圖片檔案太大,請上傳小於 5MB 的圖片。",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "已通知機台回抓最新系統設定,套用可能需要一點時間。",
"The selected product does not belong to this machine's company.": "所選商品不屬於此機台所屬的公司。",
"This Month": "本月",
"This Week": "本週",
"This account does not belong to this company.": "此帳號不屬於該公司。",
@ -2226,6 +2229,7 @@
"This machine has e-invoice disabled; cannot issue": "此機台未啟用電子發票,無法開立",
"This machine has no ECPay invoice settings": "此機台未設定綠界電子發票",
"This pharmacy pickup order can no longer be cancelled.": "此領藥單已無法作廢。",
"This product is not stocked on the selected machine; the code may not be redeemable until restocked.": "此商品未在所選機台上架,補貨前取貨碼可能無法兌換。",
"This role belongs to another company and cannot be assigned.": "此角色屬於其他公司,無法指派。",
"This slot has a pending command. Please wait.": "此貨道已有指令正在執行,請稍後。",
"This slot has a pending update. Please wait for the previous command to complete.": "此貨道已有更新指令在執行中,請等候前一個指令完成。",

View File

@ -234,7 +234,7 @@ transition-all duration-300",
__('Target Machine') }} <span class="text-rose-500">*</span></label>
<x-searchable-select name="machine_id" id="modal-pickup-machine"
placeholder="{{ __('Please select a machine') }}" x-model="selectedMachine"
@change="selectedMachine = $event.target.value; fetchSlots()">
@change="selectedMachine = $event.target.value; fetchCompanyProducts()">
@foreach($machines as $machine)
<option value="{{ $machine->id }}"
data-title="{{ $machine->name }} ({{ $machine->serial_no }})">{{ $machine->name }}
@ -244,7 +244,7 @@ transition-all duration-300",
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Select Slot') }} <span class="text-rose-500">*</span></label>
__('Select Product') }} <span class="text-rose-500">*</span></label>
<div class="relative">
<div id="slot-select-wrapper" class="relative">
{{-- Rebuilt by Alpine --}}
@ -253,6 +253,10 @@ transition-all duration-300",
<x-luxury-spinner show="loadingSlots" />
</div>
</div>
<p x-show="productNotOnMachine" x-cloak class="text-[11px] font-bold text-amber-500 pl-1 flex items-center gap-1">
<svg class="size-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 0 0-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/></svg>
{{ __('This product is not stocked on the selected machine; the code may not be redeemable until restocked.') }}
</p>
</div>
<div class="space-y-2">
@ -494,7 +498,7 @@ transition-all duration-300",
tabLoading: null,
showCreateModal: false,
selectedMachine: '',
selectedSlot: '',
selectedProduct: '',
expiresHours: 24,
maxHours: 168,
expiryMode: 'hours',
@ -503,7 +507,9 @@ transition-all duration-300",
showQrModal: false,
activeQrCode: '',
activeTicketUrl: '',
slots: [],
companyProducts: [],
machineProductIds: [],
productNotOnMachine: false,
tabLoading: null,
loadingSlots: false,
showCancelModal: false,
@ -599,18 +605,22 @@ transition-all duration-300",
return this.calculateExpiry();
},
async fetchSlots() {
// 取貨碼綁商品:下拉以「機台所屬公司的商品」為準(非僅機台貨道);
// machineProductIds 用來標示/提示「此機台目前未上架」的商品。
async fetchCompanyProducts() {
if (!this.selectedMachine) {
this.slots = [];
this.companyProducts = [];
this.machineProductIds = [];
return;
}
this.loadingSlots = true;
try {
const response = await fetch(`/admin/machines/${this.selectedMachine}/slots-ajax`);
const response = await fetch(`/admin/sales/pickup-codes/company-products/${this.selectedMachine}`);
const data = await response.json();
this.slots = data.slots || [];
this.companyProducts = data.products || [];
this.machineProductIds = (data.machine_product_ids || []).map(id => String(id));
} catch (error) {
console.error('Error fetching slots:', error);
console.error('Error fetching company products:', error);
} finally {
this.loadingSlots = false;
}
@ -621,8 +631,8 @@ transition-all duration-300",
window.showToast('{{ __("Please select a machine") }}', 'error');
return;
}
if (!this.selectedSlot) {
window.showToast('{{ __("Please select a slot") }}', 'error');
if (!this.selectedProduct) {
window.showToast('{{ __("Please select a product") }}', 'error');
return;
}
this.$refs.createForm.submit();
@ -674,6 +684,8 @@ transition-all duration-300",
this.showCancelModal = false;
},
// 取貨碼改綁商品:下拉改列「此機台有上架的商品」(由貨道清單去重),
// 提交 product_id貨道改由 App 刷碼當下依即時庫存挑選。
updateSlotSelect() {
this.$nextTick(() => {
const wrapper = document.getElementById('slot-select-wrapper');
@ -687,15 +699,15 @@ transition-all duration-300",
if (!this.selectedMachine) {
const dummy = document.createElement('div');
dummy.className = 'luxury-input opacity-50 cursor-not-allowed flex items-center justify-between';
dummy.innerHTML = `<span>{{ __("Select Slot") }}</span><svg class="size-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/></svg>`;
dummy.innerHTML = `<span>{{ __("Select Product") }}</span><svg class="size-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/></svg>`;
wrapper.appendChild(dummy);
return;
}
const selectEl = document.createElement('select');
selectEl.name = 'slot_no';
selectEl.name = 'product_id';
// Remove required to allow our custom validation toast to trigger
selectEl.id = 'modal-pickup-slot-' + Date.now();
selectEl.id = 'modal-pickup-product-' + Date.now();
selectEl.className = 'hidden';
// 清理配置字串中的換行符
@ -706,22 +718,32 @@ transition-all duration-300",
const placeholderOpt = document.createElement('option');
placeholderOpt.value = "";
placeholderOpt.textContent = "{{ __('Select Slot') }}";
placeholderOpt.setAttribute('data-title', "{{ __('Select Slot') }}");
placeholderOpt.textContent = "{{ __('Select Product') }}";
placeholderOpt.setAttribute('data-title', "{{ __('Select Product') }}");
selectEl.appendChild(placeholderOpt);
this.slots.forEach(slot => {
// 列出機台所屬公司的商品;機台目前未上架者於名稱後標示提示
this.companyProducts.forEach(product => {
const opt = document.createElement('option');
opt.value = slot.slot_no;
const text = `${slot.slot_no} - ${slot.product ? slot.product.name : 'Empty'}`;
opt.value = product.id;
const onMachine = this.machineProductIds.includes(String(product.id));
const text = onMachine ? product.name : `${product.name}{{ __('Not stocked on this machine') }}`;
opt.textContent = text;
opt.setAttribute('data-title', text);
if (this.selectedSlot == slot.slot_no) opt.selected = true;
if (this.selectedProduct == product.id) opt.selected = true;
selectEl.appendChild(opt);
});
wrapper.appendChild(selectEl);
selectEl.addEventListener('change', e => { this.selectedSlot = e.target.value; });
selectEl.addEventListener('change', e => {
this.selectedProduct = e.target.value;
// 選到機台未上架的商品 → 提示,但仍允許產碼
this.productNotOnMachine = !!this.selectedProduct
&& !this.machineProductIds.includes(String(this.selectedProduct));
if (this.productNotOnMachine) {
window.showToast('{{ __("This product is not stocked on the selected machine; the code may not be redeemable until restocked.") }}', 'warning');
}
});
if (window.HSStaticMethods?.autoInit) {
window.HSStaticMethods.autoInit(['select']);
@ -757,13 +779,14 @@ transition-all duration-300",
});
});
this.$watch('slots', () => this.updateSlotSelect());
this.$watch('companyProducts', () => this.updateSlotSelect());
this.$watch('selectedMachine', (val) => {
this.syncSelect('modal-pickup-machine', val);
this.selectedSlot = ''; // Reset slot when machine changes
this.fetchSlots();
this.selectedProduct = ''; // Reset product when machine changes
this.productNotOnMachine = false;
this.fetchCompanyProducts();
});
this.$watch('selectedSlot', (val) => {
this.$watch('selectedProduct', (val) => {
// Selection toast removed per user request
});
this.$watch('expiryMode', (mode) => {
@ -775,7 +798,8 @@ transition-all duration-300",
this.$watch('showCreateModal', (val) => {
if (val) {
this.selectedMachine = '';
this.selectedSlot = '';
this.selectedProduct = '';
this.productNotOnMachine = false;
this.usageLimit = 1;
this.expiryMode = 'hours';
this.expiresHours = 24;
@ -783,7 +807,8 @@ transition-all duration-300",
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setMinutes(0);
this.customExpiry = this.formatDate(tomorrow);
this.slots = [];
this.companyProducts = [];
this.machineProductIds = [];
this.generateRandomCode();
this.updateSlotSelect();
}

View File

@ -162,6 +162,8 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
// 取貨碼
Route::get('/pickup-codes', [App\Http\Controllers\Admin\SalesController::class, 'pickupCodes'])->name('pickup-codes');
// 產碼用:回傳機台所屬公司的商品清單 + 機台目前已上架的 product_id供前端標示「機台未上架」提示
Route::get('/pickup-codes/company-products/{machine}', [App\Http\Controllers\Admin\SalesController::class, 'pickupCompanyProducts'])->name('pickup-codes.company-products');
Route::post('/pickup-codes', [App\Http\Controllers\Admin\SalesController::class, 'storePickupCode'])->name('pickup-codes.store');
Route::patch('/pickup-codes/{pickupCode}', [App\Http\Controllers\Admin\SalesController::class, 'updatePickupCode'])->name('pickup-codes.update');
Route::delete('/pickup-codes/{pickupCode}', [App\Http\Controllers\Admin\SalesController::class, 'destroyPickupCode'])->name('pickup-codes.destroy');