From af78c48d8e7e06c3869dd5adae4dda5c85034d68 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Wed, 24 Jun 2026 14:25:33 +0800 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=E8=B2=A8=E9=81=93=E6=95=88=E6=9C=9F+?= =?UTF-8?q?=E9=8E=96=E5=AE=9A=20App=E2=86=94=E5=BE=8C=E5=8F=B0=E9=9B=99?= =?UTF-8?q?=E5=90=91=E5=90=8C=E6=AD=A5=EF=BC=88LWW=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Controllers/Admin/MachineController.php | 26 +++ .../Api/V1/App/MachineController.php | 6 + app/Models/Machine/MachineSlot.php | 7 + app/Services/Machine/MachineService.php | 161 ++++++++++++++++-- ...0000_add_slot_lock_and_lww_rev_columns.php | 31 ++++ resources/views/admin/remote/stock.blade.php | 54 ++++++ routes/web.php | 1 + 7 files changed, 268 insertions(+), 18 deletions(-) create mode 100644 database/migrations/2026_06_24_120000_add_slot_lock_and_lww_rev_columns.php diff --git a/app/Http/Controllers/Admin/MachineController.php b/app/Http/Controllers/Admin/MachineController.php index 6386ce4..4a371f5 100644 --- a/app/Http/Controllers/Admin/MachineController.php +++ b/app/Http/Controllers/Admin/MachineController.php @@ -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 價。 diff --git a/app/Http/Controllers/Api/V1/App/MachineController.php b/app/Http/Controllers/Api/V1/App/MachineController.php index 18996a4..7a99353 100644 --- a/app/Http/Controllers/Api/V1/App/MachineController.php +++ b/app/Http/Controllers/Api/V1/App/MachineController.php @@ -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); diff --git a/app/Models/Machine/MachineSlot.php b/app/Models/Machine/MachineSlot.php index c8e184a..9867c38 100644 --- a/app/Models/Machine/MachineSlot.php +++ b/app/Models/Machine/MachineSlot.php @@ -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() diff --git a/app/Services/Machine/MachineService.php b/app/Services/Machine/MachineService.php index d93b21d..12a9509 100644 --- a/app/Services/Machine/MachineService.php +++ b/app/Services/Machine/MachineService.php @@ -338,17 +338,31 @@ 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; - + // 檢查是否有實質變動 (Check for actual changes) $isStockChanged = ($oldStock !== $newStock); $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( diff --git a/database/migrations/2026_06_24_120000_add_slot_lock_and_lww_rev_columns.php b/database/migrations/2026_06_24_120000_add_slot_lock_and_lww_rev_columns.php new file mode 100644 index 0000000..b09a62f --- /dev/null +++ b/database/migrations/2026_06_24_120000_add_slot_lock_and_lww_rev_columns.php @@ -0,0 +1,31 @@ +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']); + }); + } +}; diff --git a/resources/views/admin/remote/stock.blade.php b/resources/views/admin/remote/stock.blade.php index 5e239cc..3ed8ea3 100644 --- a/resources/views/admin/remote/stock.blade.php +++ b/resources/views/admin/remote/stock.blade.php @@ -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 @@ + +
+
+
{{ + __('Sale Status') }}
+
+
+
+ +
+ diff --git a/routes/web.php b/routes/web.php index 37e2903..97af95d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');