diff --git a/app/Http/Controllers/Admin/SalesController.php b/app/Http/Controllers/Admin/SalesController.php
index 934d398..becc8e2 100644
--- a/app/Http/Controllers/Admin/SalesController.php
+++ b/app/Http/Controllers/Admin/SalesController.php
@@ -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,
+ ]);
+ }
+
/**
* 更新取貨碼 (僅限修改時間)
*/
diff --git a/app/Http/Controllers/Api/V1/App/MachineController.php b/app/Http/Controllers/Api/V1/App/MachineController.php
index e636c34..57d3167 100644
--- a/app/Http/Controllers/Api/V1/App/MachineController.php
+++ b/app/Http/Controllers/Api/V1/App/MachineController.php
@@ -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,
diff --git a/app/Models/Transaction/PickupCode.php b/app/Models/Transaction/PickupCode.php
index a5e52f4..1e84a75 100644
--- a/app/Models/Transaction/PickupCode.php
+++ b/app/Models/Transaction/PickupCode.php
@@ -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);
+ }
+
/**
* 關聯建立者
*/
diff --git a/database/migrations/2026_06_26_120000_add_product_id_to_pickup_codes_table.php b/database/migrations/2026_06_26_120000_add_product_id_to_pickup_codes_table.php
new file mode 100644
index 0000000..744a0e7
--- /dev/null
+++ b/database/migrations/2026_06_26_120000_add_product_id_to_pickup_codes_table.php
@@ -0,0 +1,28 @@
+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');
+ });
+ }
+};
diff --git a/lang/en.json b/lang/en.json
index 0bc6287..079f8ba 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -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.",
diff --git a/lang/ja.json b/lang/ja.json
index c6f3835..6fc3c9c 100644
--- a/lang/ja.json
+++ b/lang/ja.json
@@ -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.": "このスロットには保留中の更新があります。前のコマンドが完了するまでお待ちください。",
diff --git a/lang/zh_TW.json b/lang/zh_TW.json
index f898cd0..93d16c8 100644
--- a/lang/zh_TW.json
+++ b/lang/zh_TW.json
@@ -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.": "此貨道已有更新指令在執行中,請等候前一個指令完成。",
diff --git a/resources/views/admin/sales/pickup-codes/index.blade.php b/resources/views/admin/sales/pickup-codes/index.blade.php
index 2ddd259..2328787 100644
--- a/resources/views/admin/sales/pickup-codes/index.blade.php
+++ b/resources/views/admin/sales/pickup-codes/index.blade.php
@@ -234,7 +234,7 @@ transition-all duration-300",
__('Target Machine') }} *
+ @change="selectedMachine = $event.target.value; fetchCompanyProducts()">
@foreach($machines as $machine)