[FIX] 合併取貨碼/通行碼/來店禮租戶機台授權修復
This commit is contained in:
commit
7044d4cefd
@ -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 價。
|
||||
|
||||
@ -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']);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 現場值。
|
||||
* - 既有貨道 → 常態 LWW:App 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(
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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",
|
||||
|
||||
@ -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": "売上金額",
|
||||
|
||||
@ -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": "銷售金額",
|
||||
|
||||
@ -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 -->
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user