[FIX] 合併取貨碼/通行碼/來店禮租戶機台授權修復

This commit is contained in:
sky121113 2026-06-24 15:40:33 +08:00
commit 7044d4cefd
15 changed files with 392 additions and 44 deletions

View File

@ -265,6 +265,32 @@ class MachineController extends AdminController
}
}
/**
* AJAX: 遠端鎖定/解鎖單一貨道(暫停販售)。
* updateSlotExpiry 共用 updateSlot 下發 update_inventory只帶 is_locked不動效期/庫存。
*/
public function toggleSlotLock(Request $request, Machine $machine)
{
$validated = $request->validate([
'slot_no' => 'required|integer',
'is_locked' => 'required|boolean',
]);
try {
$this->machineService->updateSlot($machine, $validated, auth()->id());
return response()->json([
'success' => true,
'message' => $validated['is_locked'] ? __('Slot locked.') : __('Slot unlocked.'),
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 422);
}
}
/**
* 機台專屬定價頁:列出該機台「所屬公司的完整商品目錄」(非僅貨道商品),
* 可在上貨前先把機台價/會員價訂好。未填=沿用全域 products 價。

View File

@ -194,7 +194,10 @@ class SalesController extends Controller
// 1. 取貨碼列表 (list)
if (!$isAjax || $tab === 'list') {
$query = PickupCode::with(['machine.slots.product', 'creator', 'order'])->latest();
// 僅顯示目前帳號可存取機台的取貨碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = PickupCode::with(['machine.slots.product', 'creator', 'order'])
->whereHas('machine')
->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
@ -219,7 +222,12 @@ class SalesController extends Controller
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
$logQuery = PickupCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'pickupCode', 'order']);
// 同上操作紀錄只顯示可存取機台的資料machine_id 為 null 的非機台紀錄仍保留)
$logQuery = PickupCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'pickupCode', 'order'])
->where(function ($q) {
$q->whereNull('machine_id')
->orWhereHas('machine');
});
if ($request->filled('search_log')) {
$search = $request->input('search_log');
@ -362,6 +370,11 @@ class SalesController extends Controller
*/
public function destroyPickupCode(PickupCode $pickupCode)
{
// 機台授權以 machine_user 為準:非系統管理員若無法存取該取貨碼所屬機台則拒絕
if (!Auth::user()->isSystemAdmin() && !Machine::whereKey($pickupCode->machine_id)->exists()) {
abort(403);
}
$oldValues = $pickupCode->toArray();
$pickupCode->update(['status' => 'cancelled']);
@ -411,7 +424,10 @@ class SalesController extends Controller
// 1. 通行碼列表 (list)
if (!$isAjax || $tab === 'list') {
$query = PassCode::with(['machine', 'creator'])->latest();
// 僅顯示目前帳號可存取機台的通行碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = PassCode::with(['machine', 'creator'])
->whereHas('machine')
->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
@ -447,7 +463,9 @@ class SalesController extends Controller
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
$logQuery = PassCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'passCode']);
// 同上操作紀錄只顯示可存取機台的資料pass_code_logs.machine_id 為 NOT NULL
$logQuery = PassCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'passCode'])
->whereHas('machine');
if ($request->filled('search_log')) {
$search = $request->input('search_log');
@ -587,6 +605,11 @@ class SalesController extends Controller
*/
public function destroyPassCode(PassCode $passCode)
{
// 機台授權以 machine_user 為準:非系統管理員若無法存取該通行碼所屬機台則拒絕
if (!Auth::user()->isSystemAdmin() && !Machine::whereKey($passCode->machine_id)->exists()) {
abort(403);
}
$oldValues = $passCode->toArray();
$passCode->update(['status' => 'disabled']);
@ -618,7 +641,10 @@ class SalesController extends Controller
// 1. 來店禮列表 (list)
if (!$isAjax || $tab === 'list') {
$query = WelcomeGift::with(['machine', 'creator'])->latest();
// 僅顯示目前帳號可存取機台的來店禮(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = WelcomeGift::with(['machine', 'creator'])
->whereHas('machine')
->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
@ -654,7 +680,9 @@ class SalesController extends Controller
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
$logQuery = WelcomeGiftLog::with(['machine:id,name,serial_no', 'welcomeGift', 'order:id,order_no']);
// 同上操作紀錄只顯示可存取機台的資料welcome_gift_logs.machine_id 為 NOT NULL
$logQuery = WelcomeGiftLog::with(['machine:id,name,serial_no', 'welcomeGift', 'order:id,order_no'])
->whereHas('machine');
if ($request->filled('search_log')) {
$search = $request->input('search_log');
@ -846,6 +874,11 @@ class SalesController extends Controller
*/
public function destroyWelcomeGift(WelcomeGift $welcomeGift)
{
// 機台授權以 machine_user 為準:非系統管理員若無法存取該來店禮所屬機台則拒絕
if (!Auth::user()->isSystemAdmin() && !Machine::whereKey($welcomeGift->machine_id)->exists()) {
abort(403);
}
$oldValues = $welcomeGift->toArray();
$welcomeGift->update(['status' => 'disabled']);

View File

@ -371,6 +371,12 @@ class MachineController extends Controller
'product_id' => $item['t060v00'] ?? null,
'stock' => isset($item['num']) ? (int) $item['num'] : 0,
'type' => isset($item['type']) ? (int) $item['type'] : null,
// 雙向 LWW鎖定與效期/批號 + 各自的編輯序號App 端 store 編輯才遞增)
'is_locked' => isset($item['is_locked']) ? (bool) $item['is_locked'] : false,
'lock_rev' => isset($item['lock_rev']) ? (int) $item['lock_rev'] : 0,
'expiry_date' => $item['expiry_date'] ?? null,
'batch_no' => $item['batch_no'] ?? null,
'expiry_rev' => isset($item['expiry_rev']) ? (int) $item['expiry_rev'] : 0,
];
}, $legacyData);

View File

@ -20,12 +20,19 @@ class MachineSlot extends Model
'expiry_date',
'batch_no',
'is_active',
'is_locked',
'last_app_lock_rev',
'last_app_expiry_rev',
];
protected $casts = [
'price' => 'decimal:2',
'last_restocked_at' => 'datetime',
'expiry_date' => 'date:Y-m-d',
'is_active' => 'boolean',
'is_locked' => 'boolean',
'last_app_lock_rev' => 'integer',
'last_app_expiry_rev' => 'integer',
];
public function machine()

View File

@ -338,6 +338,13 @@ class MachineService
'is_active' => true,
];
// ── 雙向 LWW鎖定(lock) 與 效期/批號(expiry/batch),各自獨立 rev ──
$updateData = array_merge(
$updateData,
$this->resolveSlotLockLww($existingSlot, $slotData),
$this->resolveSlotExpiryLww($existingSlot, $slotData, $actualProductId)
);
if ($existingSlot) {
$oldStock = (int) $existingSlot->stock;
$oldProductId = $existingSlot->product_id;
@ -347,8 +354,15 @@ class MachineService
$isProductChanged = ($oldProductId != $actualProductId); // 這裡用鬆散比對以處理 null/0 的情況
$isSlotConfigChanged = (string) $existingSlot->type !== (string) $updateData['type']
|| (int) $existingSlot->max_stock !== (int) $updateData['max_stock'];
// lock/expiry/rev 也算實質變動,確保 LWW 結果會被寫回(即使 stock/product 沒變)
$curExpiryStr = $existingSlot->expiry_date ? $existingSlot->expiry_date->format('Y-m-d') : null;
$isLwwChanged = ((bool) $existingSlot->is_locked !== (bool) $updateData['is_locked'])
|| ((int) $existingSlot->last_app_lock_rev !== (int) $updateData['last_app_lock_rev'])
|| ((int) $existingSlot->last_app_expiry_rev !== (int) $updateData['last_app_expiry_rev'])
|| ($curExpiryStr !== $updateData['expiry_date'])
|| ($existingSlot->batch_no !== $updateData['batch_no']);
if ($isStockChanged || $isProductChanged || $isSlotConfigChanged) {
if ($isStockChanged || $isProductChanged || $isSlotConfigChanged || $isLwwChanged) {
$existingSlot->update($updateData);
$existingSlot->refresh();
@ -422,6 +436,88 @@ class MachineService
});
}
/**
* 空字串/"null" 一律歸一化為 null(避免 MySQL date 欄位寫入空字串解析爆)。
*/
private function blankToNull($value)
{
if (is_string($value)) {
$value = trim($value);
}
if ($value === '' || $value === null || (is_string($value) && strtolower($value) === 'null')) {
return null;
}
return $value;
}
/**
* 取日期字串的 yyyy-MM-dd 部分(容錯後台/機台帶了時間),空值回 null
*/
private function normalizeDate($value)
{
$value = $this->blankToNull($value);
if ($value === null) {
return null;
}
return substr((string) $value, 0, 10);
}
/**
* 貨道鎖定(lock)的雙向 LWW 解析,回傳要寫入的 is_locked / last_app_lock_rev。
* - 新貨道 App 現場值。
* - 既有貨道 常態 LWWApp rev 較大才採用,否則保留後台值。
* 既有機台升級時App onUpgrade 已把現場既有鎖定的 lock_rev 設為 1,故能 1>0 自動遷移上雲。)
*/
private function resolveSlotLockLww(?MachineSlot $existingSlot, array $slotData): array
{
$appLocked = (bool) ($slotData['is_locked'] ?? false);
$appRev = (int) ($slotData['lock_rev'] ?? 0);
if (!$existingSlot) {
$newRev = $appRev > 0 ? $appRev : ($appLocked ? 1 : 0);
return ['is_locked' => $appLocked, 'last_app_lock_rev' => $newRev];
}
$curLocked = (bool) $existingSlot->is_locked;
$curRev = (int) $existingSlot->last_app_lock_rev;
if ($appRev > $curRev) {
return ['is_locked' => $appLocked, 'last_app_lock_rev' => $appRev];
}
return ['is_locked' => $curLocked, 'last_app_lock_rev' => $curRev];
}
/**
* 效期/批號(expiry/batch)的雙向 LWW 解析,回傳要寫入的 expiry_date / batch_no / last_app_expiry_rev。
* 換商品(product_id 變更)時預設清空舊效期(安全網),除非 App 同筆帶了較新的新效期。
*/
private function resolveSlotExpiryLww(?MachineSlot $existingSlot, array $slotData, $actualProductId): array
{
$appExpiry = $this->normalizeDate($slotData['expiry_date'] ?? null);
$appBatch = $this->blankToNull($slotData['batch_no'] ?? null);
$appRev = (int) ($slotData['expiry_rev'] ?? 0);
if (!$existingSlot) {
$newRev = $appRev > 0 ? $appRev : ($appExpiry !== null ? 1 : 0);
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $newRev];
}
$curExpiry = $existingSlot->expiry_date ? $existingSlot->expiry_date->format('Y-m-d') : null;
$curBatch = $this->blankToNull($existingSlot->batch_no);
$curRev = (int) $existingSlot->last_app_expiry_rev;
// 換商品安全網:預設清空,除非 App 同筆帶了較新的新效期
$isProductChanged = ((string) ($existingSlot->product_id) !== (string) $actualProductId);
if ($isProductChanged) {
if ($appExpiry !== null && $appRev > $curRev) {
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $appRev];
}
return ['expiry_date' => null, 'batch_no' => null, 'last_app_expiry_rev' => max($curRev, $appRev)];
}
if ($appRev > $curRev) {
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $appRev];
}
return ['expiry_date' => $curExpiry, 'batch_no' => $curBatch, 'last_app_expiry_rev' => $curRev];
}
public function syncProductSlotMaxStock(Product $product): void
{
MachineSlot::query()
@ -523,19 +619,42 @@ class MachineService
}
// 紀錄舊數據以供回滾使用
$oldExpiry = $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null;
$oldData = [
'stock' => $slot->stock,
'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null,
'expiry_date' => $oldExpiry,
'batch_no' => $slot->batch_no,
'is_locked' => (bool) $slot->is_locked,
];
// 2. 執行樂觀更新 (Optimistic Update)
$updateData = [
'expiry_date' => $expiryDate,
'batch_no' => $batchNo,
];
if ($stock !== null) $updateData['stock'] = (int)$stock;
$slot->update($updateData);
// 2. 執行樂觀更新 (Optimistic Update):只更新「明確帶上的欄位」,避免鎖定切換誤清效期
$updateData = [];
$normExpiry = $oldExpiry;
if (array_key_exists('expiry_date', $data)) {
$normExpiry = $this->normalizeDate($expiryDate);
$updateData['expiry_date'] = $normExpiry;
}
$normBatch = $slot->batch_no;
if (array_key_exists('batch_no', $data)) {
$normBatch = $this->blankToNull($batchNo);
$updateData['batch_no'] = $normBatch;
}
if ($stock !== null) $updateData['stock'] = (int) $stock;
if (array_key_exists('is_locked', $data)) {
$updateData['is_locked'] = (bool) $data['is_locked'];
}
// 後台編輯效期/批號或鎖定 → 遞增對應 rev成為權威值下行帶給 App 對齊 synced_rev
$expiryEdited = ($normExpiry !== $oldExpiry) || ($normBatch !== $slot->batch_no);
if ($expiryEdited) {
$updateData['last_app_expiry_rev'] = (int) $slot->last_app_expiry_rev + 1;
}
$lockEdited = array_key_exists('is_locked', $data) && ((bool) $data['is_locked'] !== (bool) $slot->is_locked);
if ($lockEdited) {
$updateData['last_app_lock_rev'] = (int) $slot->last_app_lock_rev + 1;
}
if (!empty($updateData)) $slot->update($updateData);
// 3. 若庫存數值有異動,記錄 adjustment 流水帳
if ($stock !== null && (int)$stock !== $oldData['stock']) {
@ -551,6 +670,8 @@ class MachineService
);
}
$slot->refresh();
// 3. 建立遠端指令紀錄
$command = RemoteCommand::create([
'machine_id' => $machine->id,
@ -561,19 +682,23 @@ class MachineService
'slot_no' => (string)$slotNo,
'old' => $oldData,
'new' => [
'stock' => $stock !== null ? (int)$stock : $oldData['stock'],
'expiry_date' => $expiryDate ?: null,
'batch_no' => $batchNo ?: null,
'stock' => (int) $slot->stock,
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
'batch_no' => $slot->batch_no,
'is_locked' => (bool) $slot->is_locked,
]
]
]);
// 4. 推播 MQTT
// 4. 推播 MQTT(一律帶上目前權威的 is_locked 與雙 rev讓 App 端對齊 synced_rev
$mqttPayload = [
'slot_no' => $command->payload['slot_no'],
'stock' => $command->payload['new']['stock'],
'expiry_date' => $command->payload['new']['expiry_date'],
'batch_no' => $command->payload['new']['batch_no'] ?? null,
'slot_no' => (string) $slotNo,
'stock' => (int) $slot->stock,
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
'batch_no' => $slot->batch_no,
'is_locked' => (bool) $slot->is_locked,
'lock_rev' => (int) $slot->last_app_lock_rev,
'expiry_rev' => (int) $slot->last_app_expiry_rev,
];
app(\App\Services\Machine\MqttService::class)->pushCommand(

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 貨道「效期 + 鎖定」雙向 LWW 同步所需欄位。
*
* - machine_slots.is_locked : 貨道是否被「鎖定停售」(與 is_active「是否啟用/配置」語意不同,兩者並存)。
* - machine_slots.last_app_lock_rev : 後台已採用的 App 鎖定編輯序號per-field LWW避免比較機台/後台兩個時鐘)。
* - machine_slots.last_app_expiry_rev: 後台已採用的 App 效期/批號編輯序號。
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('machine_slots', function (Blueprint $table) {
$table->boolean('is_locked')->default(false)->after('is_active');
$table->unsignedBigInteger('last_app_lock_rev')->default(0)->after('is_locked');
$table->unsignedBigInteger('last_app_expiry_rev')->default(0)->after('last_app_lock_rev');
});
}
public function down(): void
{
Schema::table('machine_slots', function (Blueprint $table) {
$table->dropColumn(['is_locked', 'last_app_lock_rev', 'last_app_expiry_rev']);
});
}
};

View File

@ -1050,11 +1050,13 @@
"Location found!": "Location found!",
"Location not found": "Location not found",
"Lock": "Lock",
"Lock (Pause Sale)": "Lock (Pause Sale)",
"Lock Now": "Lock Now",
"Lock Page": "Lock Page",
"Lock Page Lock": "Lock Page Lock",
"Lock Page Unlock": "Lock Page Unlock",
"Locked": "Locked",
"Locked (Sale Paused)": "Locked (Sale Paused)",
"Locked Page": "Locked Page",
"Log Details": "Log Details",
"Log Time": "Log Time",
@ -1423,6 +1425,7 @@
"Offline Machines": "Offline Machines",
"Old": "Old",
"Old Value": "Old Value",
"On Sale": "On Sale",
"Once": "Once",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.",
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.",
@ -1826,7 +1829,9 @@
"Running Status": "Running Status",
"SYSTEM": "SYSTEM",
"Safety Stock": "Safety Stock",
"Sale Paused": "Sale Paused",
"Sale Price": "Sale Price",
"Sale Status": "Sale Status",
"Sales": "Sales",
"Sales Activity": "Sales Activity",
"Sales Amount": "Sales Amount",

View File

@ -1050,11 +1050,13 @@
"Location found!": "座標を取得しました!",
"Location not found": "場所が見つかりません。ランドマーク名(例:花蓮県庁)を使用してみてください",
"Lock": "ロック",
"Lock (Pause Sale)": "ロック(販売停止)",
"Lock Now": "今すぐロック",
"Lock Page": "ページロック",
"Lock Page Lock": "機器アプリロック",
"Lock Page Unlock": "機器アプリロック解除",
"Locked": "ロック中",
"Locked (Sale Paused)": "ロック中(販売停止)",
"Locked Page": "ロックページ",
"Log Details": "Log Details",
"Log Time": "記録時間",
@ -1423,6 +1425,7 @@
"Offline Machines": "オフライン機器",
"Old": "Old",
"Old Value": "Old Value",
"On Sale": "販売中",
"Once": "一回のみ",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "アカウントが削除されると、すべてのリソースとデータは完全に削除されます。アカウントを削除する前に、保持したいデータや情報をダウンロードしてください。",
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "アカウントが削除されると、すべてのデータは永久に削除されます。完全に削除することを確認するためにパスワードを入力してください。",
@ -1826,7 +1829,9 @@
"Running Status": "稼働ステータス",
"SYSTEM": "システムレベル",
"Safety Stock": "安全在庫",
"Sale Paused": "販売停止",
"Sale Price": "販売価格",
"Sale Status": "販売状態",
"Sales": "販売管理",
"Sales Activity": "販売活動",
"Sales Amount": "売上金額",

View File

@ -1050,11 +1050,13 @@
"Location found!": "已成功獲取座標!",
"Location not found": "找不到位置,您可以嘗試使用地標名稱(例如:花蓮縣政府)",
"Lock": "鎖定",
"Lock (Pause Sale)": "鎖定(暫停販售)",
"Lock Now": "立即鎖定",
"Lock Page": "頁面鎖定",
"Lock Page Lock": "機台 APP 鎖定",
"Lock Page Unlock": "機台 APP 解鎖",
"Locked": "尚未解鎖",
"Locked (Sale Paused)": "已鎖定(暫停販售)",
"Locked Page": "鎖定頁",
"Log Details": "操作紀錄詳情",
"Log Time": "記錄時間",
@ -1423,6 +1425,7 @@
"Offline Machines": "離線機台",
"Old": "舊值",
"Old Value": "舊資料",
"On Sale": "販售中",
"Once": "單次使用",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "一旦您的帳號被刪除,其所有資源和數據將被永久刪除。在刪除帳號之前,請下載您希望保留的任何數據或資訊。",
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "帳號一旦刪除,所有關連數據將被永久移除。請輸入您的密碼以確認您希望永久刪除此帳號。",
@ -1826,7 +1829,9 @@
"Running Status": "運行狀態",
"SYSTEM": "系統層級",
"Safety Stock": "安全庫存",
"Sale Paused": "暫停販售",
"Sale Price": "售價",
"Sale Status": "銷售狀態",
"Sales": "銷售管理",
"Sales Activity": "銷售活動",
"Sales Amount": "銷售金額",

View File

@ -251,6 +251,42 @@
}
},
async toggleLock() {
this.updating = true;
try {
const csrf = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const res = await fetch(`/admin/machines/${this.selectedMachine.id}/slots/lock`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrf
},
body: JSON.stringify({
slot_no: this.selectedSlot.slot_no,
is_locked: !this.selectedSlot.is_locked
})
});
const data = await res.json();
if (data.success) {
this.showEditModal = false;
window.location.href = "{{ route('admin.remote.stock') }}";
} else {
throw new Error(data.message || 'Unknown error');
}
} catch (e) {
window.dispatchEvent(new CustomEvent('toast', {
detail: {
message: '{{ __("Save error:") }} ' + e.message,
type: 'error'
}
}));
console.error('Lock toggle error:', e);
} finally {
this.updating = false;
}
},
getSlotColorClass(slot) {
const today = new Date().toISOString().split('T')[0];
if (slot.expiry_date && slot.expiry_date < today) {
@ -352,6 +388,14 @@
details += `{{ __('Batch') }} ${p.old.batch_no || 'N/A'} → ${p.new.batch_no || 'N/A'}`;
}
// 鎖定/解鎖暫停販售切換old/new 僅 is_locked 不同時,避免顯示空白徽章
if ((p.old.is_locked ?? false) !== (p.new.is_locked ?? false)) {
if (details !== `{{ __('Slot') }} ${p.slot_no}: `) details += ', ';
details += p.new.is_locked
? `{{ __('Lock (Pause Sale)') }}`
: `{{ __('Unlock') }}`;
}
return details;
}
return '';
@ -666,12 +710,21 @@
class="text-xs font-black uppercase tracking-tighter text-slate-800 dark:text-white"
x-text="slot.slot_no"></span>
</div>
<template x-if="slot.max_stock > 0 && slot.stock <= (slot.max_stock * 0.2)">
<div
class="px-2.5 py-1.5 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 animate-pulse whitespace-nowrap select-none">
{{ __('Low') }}
</div>
</template>
<div class="flex items-center gap-1.5">
<template x-if="slot.is_locked">
<div
class="flex items-center gap-1 px-2.5 py-1.5 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 whitespace-nowrap select-none">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Sale Paused') }}
</div>
</template>
<template x-if="slot.max_stock > 0 && slot.stock <= (slot.max_stock * 0.2)">
<div
class="px-2.5 py-1.5 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 animate-pulse whitespace-nowrap select-none">
{{ __('Low') }}
</div>
</template>
</div>
</div>
<!-- Product Image -->
@ -777,8 +830,16 @@
</template>
</div>
<div>
<div class="text-base font-black text-slate-800 dark:text-slate-200"
x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></div>
<div class="flex items-center gap-2">
<span class="text-base font-black text-slate-800 dark:text-slate-200"
x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></span>
<template x-if="slot.is_locked">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-500 text-[10px] font-black uppercase tracking-wider whitespace-nowrap">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Locked (Sale Paused)') }}
</span>
</template>
</div>
<template x-if="slot.product?.id">
<div class="mt-1">
<span class="text-xs font-bold text-slate-400 dark:text-slate-500 font-mono tracking-widest uppercase">{{ __('ID') }}: <span x-text="slot.product.id"></span></span>
@ -843,7 +904,12 @@
</div>
</div>
<div class="shrink-0 text-right">
<span class="text-xs font-black px-2.5 py-1.5 rounded-lg bg-slate-900/10 dark:bg-white/10" x-text="slot.slot_no"></span>
<template x-if="slot.is_locked">
<span class="inline-flex items-center gap-1 text-[10px] font-black px-2.5 py-1.5 rounded-lg bg-rose-500/10 text-rose-500 uppercase tracking-wider whitespace-nowrap">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Sale Paused') }}
</span>
</template>
</div>
</div>
@ -966,6 +1032,24 @@
</div>
</div>
<!-- Slot Lock (暫停販售):遠端鎖定/解鎖此貨道,與機台現場雙向同步 -->
<div
class="mt-6 flex items-center justify-between rounded-2xl border border-slate-100 dark:border-slate-800/50 px-5 py-4">
<div>
<div class="text-sm font-black text-slate-500 uppercase tracking-widest">{{
__('Sale Status') }}</div>
<div class="mt-1 text-xs font-bold"
:class="selectedSlot?.is_locked ? 'text-rose-500' : 'text-emerald-500'"
x-text="selectedSlot?.is_locked ? '{{ __('Locked (Sale Paused)') }}' : '{{ __('On Sale') }}'">
</div>
</div>
<button type="button" @click="toggleLock()" :disabled="updating"
class="px-6 py-3 rounded-xl font-bold transition disabled:opacity-50"
:class="selectedSlot?.is_locked ? 'bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20' : 'bg-rose-500/10 text-rose-600 hover:bg-rose-500/20'"
x-text="selectedSlot?.is_locked ? '{{ __('Unlock') }}' : '{{ __('Lock (Pause Sale)') }}'">
</button>
</div>
</div>
<!-- Footer Actions -->

View File

@ -109,7 +109,7 @@
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">
{{ $code->name }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{
$code->machine->name }}</div>
$code->machine?->name ?? '-' }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
@if($code->expires_at)
@ -241,7 +241,7 @@
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Machine') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate">{{
$code->machine->name }}</p>
$code->machine?->name ?? '-' }}</p>
</div>
<div>
<p

View File

@ -117,15 +117,15 @@
<td class="px-6 py-6">
<div
class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">
{{ $code->machine->name }}</div>
{{ $code->machine?->name ?? '-' }}</div>
<div
class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em]">
{{
$code->machine->serial_no }}</div>
$code->machine?->serial_no }}</div>
</td>
<td class="px-6 py-6">
@php
$slot = $code->machine->slots->where('slot_no', $code->slot_no)->first();
$slot = $code->machine?->slots->where('slot_no', $code->slot_no)->first();
$productName = $slot ? ($slot->product?->name ?? __('Empty')) : __('Empty');
@endphp
<div class="text-sm font-black text-cyan-600 dark:text-cyan-400 mb-0.5">{{
@ -251,7 +251,7 @@
@endif
<p
class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
{{ $code->machine->name }}
{{ $code->machine?->name ?? '-' }}
</p>
</div>
</div>
@ -287,7 +287,7 @@
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">
{{ $code->slot_no }}
@php
$slotMobile = $code->machine->slots->where('slot_no',
$slotMobile = $code->machine?->slots->where('slot_no',
$code->slot_no)->first();
$prodMobile = $slotMobile ? ($slotMobile->product?->name ?? __('Empty')) :
__('Empty');

View File

@ -108,7 +108,7 @@
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">
{{ $gift->name }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{
$gift->machine->name }}</div>
$gift->machine?->name ?? '-' }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<span class="text-sm font-black text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-500/10 px-3 py-1 rounded-lg border border-indigo-100 dark:border-indigo-500/20">
@ -258,7 +258,7 @@
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Machine') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate">
{{ $gift->machine->name }}</p>
{{ $gift->machine?->name ?? '-' }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">

View File

@ -296,6 +296,12 @@
<span class="text-sm font-black tracking-tighter text-slate-800 dark:text-white"
x-text="slot.slot_no"></span>
</div>
<template x-if="slot.is_locked">
<div class="absolute top-4 right-5 flex items-center gap-1 px-2.5 py-1.5 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 whitespace-nowrap select-none">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Sale Paused') }}
</div>
</template>
<div class="relative w-16 h-16 mb-4 mt-2">
<div
@ -364,8 +370,16 @@
</template>
</div>
<div>
<div class="text-base font-black text-slate-800 dark:text-slate-200"
x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></div>
<div class="flex items-center gap-2">
<span class="text-base font-black text-slate-800 dark:text-slate-200"
x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></span>
<template x-if="slot.is_locked">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-500 text-[10px] font-black uppercase tracking-wider whitespace-nowrap">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Locked (Sale Paused)') }}
</span>
</template>
</div>
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1 mt-1">
<template x-if="slot.product?.id">
<span class="text-xs font-bold text-slate-400 dark:text-slate-500 font-mono tracking-widest uppercase">{{ __('ID') }}: <span x-text="slot.product.id"></span></span>
@ -435,6 +449,12 @@
</div>
<div class="shrink-0 text-right">
<p class="text-base font-black text-emerald-500" x-text="'$' + Math.floor(slot.product?.price || 0)"></p>
<template x-if="slot.is_locked">
<span class="inline-flex items-center gap-1 mt-1 text-[10px] font-black px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-500 uppercase tracking-wider whitespace-nowrap">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Sale Paused') }}
</span>
</template>
</div>
</div>

View File

@ -68,6 +68,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::get('/utilization-ajax/{id?}', [App\Http\Controllers\Admin\MachineController::class, 'utilizationData'])->name('utilization-ajax');
Route::get('/{machine}/slots-ajax', [App\Http\Controllers\Admin\MachineController::class, 'slotsAjax'])->name('slots-ajax');
Route::post('/{machine}/slots/expiry', [App\Http\Controllers\Admin\MachineController::class, 'updateSlotExpiry'])->name('slots.expiry.update');
Route::post('/{machine}/slots/lock', [App\Http\Controllers\Admin\MachineController::class, 'toggleSlotLock'])->name('slots.lock.toggle');
// 機台專屬定價(獨立頁,列全公司目錄,可上貨前先定價)
Route::get('/{machine}/pricing', [App\Http\Controllers\Admin\MachineController::class, 'pricing'])->name('pricing');
Route::post('/{machine}/pricing', [App\Http\Controllers\Admin\MachineController::class, 'updatePricing'])->name('pricing.update');