[FEAT] 貨道效期+鎖定 App↔後台雙向同步(LWW)
1. machine_slots 新增 is_locked / last_app_lock_rev / last_app_expiry_rev(migration);MachineSlot model fillable+casts。 2. syncSlots(B009):新增 per-field 常態 LWW(App rev 較大才採用);換商品(product_id 變更)時清空舊 expiry/batch 安全網;空值一律歸一化為 null。 3. updateSlot:下行 update_inventory 一律帶 is_locked + lock_rev + expiry_rev,後台編輯只更新「帶上的欄位」(鎖定切換不會清效期),對應 rev +1 成為權威值。 4. 新增 admin slots/lock endpoint(toggleSlotLock)+ route;remote/stock.blade.php 貨道編輯彈窗加「販售狀態/鎖定·解鎖」toggle。 5. 不含 app_uuid/重灌/B017 概念(精簡版);既有現場鎖定靠 App 端升級遷移(lock_rev=1)走常態 LWW 上雲。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
47073f0a96
commit
af78c48d8e
@ -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 價。
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -420,6 +434,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()
|
||||
@ -521,19 +617,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']) {
|
||||
@ -549,6 +668,8 @@ class MachineService
|
||||
);
|
||||
}
|
||||
|
||||
$slot->refresh();
|
||||
|
||||
// 3. 建立遠端指令紀錄
|
||||
$command = RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
@ -559,19 +680,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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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) {
|
||||
@ -966,6 +1002,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 -->
|
||||
|
||||
@ -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