[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:
sky121113 2026-06-24 14:25:33 +08:00
parent 47073f0a96
commit af78c48d8e
7 changed files with 268 additions and 18 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 價。 * 可在上貨前先把機台價/會員價訂好。未填=沿用全域 products 價。

View File

@ -371,6 +371,12 @@ class MachineController extends Controller
'product_id' => $item['t060v00'] ?? null, 'product_id' => $item['t060v00'] ?? null,
'stock' => isset($item['num']) ? (int) $item['num'] : 0, 'stock' => isset($item['num']) ? (int) $item['num'] : 0,
'type' => isset($item['type']) ? (int) $item['type'] : null, '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); }, $legacyData);

View File

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

View File

@ -338,17 +338,31 @@ class MachineService
'is_active' => true, 'is_active' => true,
]; ];
// ── 雙向 LWW鎖定(lock) 與 效期/批號(expiry/batch),各自獨立 rev ──
$updateData = array_merge(
$updateData,
$this->resolveSlotLockLww($existingSlot, $slotData),
$this->resolveSlotExpiryLww($existingSlot, $slotData, $actualProductId)
);
if ($existingSlot) { if ($existingSlot) {
$oldStock = (int) $existingSlot->stock; $oldStock = (int) $existingSlot->stock;
$oldProductId = $existingSlot->product_id; $oldProductId = $existingSlot->product_id;
// 檢查是否有實質變動 (Check for actual changes) // 檢查是否有實質變動 (Check for actual changes)
$isStockChanged = ($oldStock !== $newStock); $isStockChanged = ($oldStock !== $newStock);
$isProductChanged = ($oldProductId != $actualProductId); // 這裡用鬆散比對以處理 null/0 的情況 $isProductChanged = ($oldProductId != $actualProductId); // 這裡用鬆散比對以處理 null/0 的情況
$isSlotConfigChanged = (string) $existingSlot->type !== (string) $updateData['type'] $isSlotConfigChanged = (string) $existingSlot->type !== (string) $updateData['type']
|| (int) $existingSlot->max_stock !== (int) $updateData['max_stock']; || (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->update($updateData);
$existingSlot->refresh(); $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 現場值。
* - 既有貨道 常態 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 public function syncProductSlotMaxStock(Product $product): void
{ {
MachineSlot::query() MachineSlot::query()
@ -521,19 +617,42 @@ class MachineService
} }
// 紀錄舊數據以供回滾使用 // 紀錄舊數據以供回滾使用
$oldExpiry = $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null;
$oldData = [ $oldData = [
'stock' => $slot->stock, 'stock' => $slot->stock,
'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null, 'expiry_date' => $oldExpiry,
'batch_no' => $slot->batch_no, 'batch_no' => $slot->batch_no,
'is_locked' => (bool) $slot->is_locked,
]; ];
// 2. 執行樂觀更新 (Optimistic Update) // 2. 執行樂觀更新 (Optimistic Update):只更新「明確帶上的欄位」,避免鎖定切換誤清效期
$updateData = [ $updateData = [];
'expiry_date' => $expiryDate, $normExpiry = $oldExpiry;
'batch_no' => $batchNo, if (array_key_exists('expiry_date', $data)) {
]; $normExpiry = $this->normalizeDate($expiryDate);
if ($stock !== null) $updateData['stock'] = (int)$stock; $updateData['expiry_date'] = $normExpiry;
$slot->update($updateData); }
$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 流水帳 // 3. 若庫存數值有異動,記錄 adjustment 流水帳
if ($stock !== null && (int)$stock !== $oldData['stock']) { if ($stock !== null && (int)$stock !== $oldData['stock']) {
@ -549,6 +668,8 @@ class MachineService
); );
} }
$slot->refresh();
// 3. 建立遠端指令紀錄 // 3. 建立遠端指令紀錄
$command = RemoteCommand::create([ $command = RemoteCommand::create([
'machine_id' => $machine->id, 'machine_id' => $machine->id,
@ -559,19 +680,23 @@ class MachineService
'slot_no' => (string)$slotNo, 'slot_no' => (string)$slotNo,
'old' => $oldData, 'old' => $oldData,
'new' => [ 'new' => [
'stock' => $stock !== null ? (int)$stock : $oldData['stock'], 'stock' => (int) $slot->stock,
'expiry_date' => $expiryDate ?: null, 'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
'batch_no' => $batchNo ?: null, 'batch_no' => $slot->batch_no,
'is_locked' => (bool) $slot->is_locked,
] ]
] ]
]); ]);
// 4. 推播 MQTT // 4. 推播 MQTT(一律帶上目前權威的 is_locked 與雙 rev讓 App 端對齊 synced_rev
$mqttPayload = [ $mqttPayload = [
'slot_no' => $command->payload['slot_no'], 'slot_no' => (string) $slotNo,
'stock' => $command->payload['new']['stock'], 'stock' => (int) $slot->stock,
'expiry_date' => $command->payload['new']['expiry_date'], 'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
'batch_no' => $command->payload['new']['batch_no'] ?? 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( 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

@ -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) { getSlotColorClass(slot) {
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
if (slot.expiry_date && slot.expiry_date < today) { if (slot.expiry_date && slot.expiry_date < today) {
@ -966,6 +1002,24 @@
</div> </div>
</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> </div>
<!-- Footer Actions --> <!-- Footer Actions -->

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('/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::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/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::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'); Route::post('/{machine}/pricing', [App\Http\Controllers\Admin\MachineController::class, 'updatePricing'])->name('pricing.update');