diff --git a/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php b/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php index b007952..042c9c3 100644 --- a/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php +++ b/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php @@ -3,12 +3,17 @@ namespace App\Http\Controllers\Admin\BasicSettings; use App\Http\Controllers\Admin\AdminController; +use App\Jobs\Product\SendProductSyncCommandJob; use App\Models\Machine\Machine; use App\Models\Machine\MachineModel; +use App\Models\Machine\MachineProductPrice; +use App\Models\Product\Product; use App\Models\System\Company; use App\Models\System\PaymentConfig; use App\Traits\ImageHandler; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\View\View; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Storage; @@ -197,6 +202,91 @@ class MachineSettingController extends AdminController return view('admin.basic-settings.machines.edit', compact('machine', 'models', 'paymentConfigs', 'companies')); } + /** + * 機台專屬定價頁:列出該機台「所屬公司的完整商品目錄」(非僅貨道商品), + * 可在上貨前先把機台價/會員價訂好。未填=沿用全域 products 價。 + */ + public function pricing(Machine $machine): View + { + $products = Product::where('company_id', $machine->company_id) + ->active() + ->orderBy('name') + ->get(['id', 'name', 'image_url', 'barcode', 'price', 'member_price']); + + $overrides = $machine->productPrices()->get()->keyBy('product_id'); + + $items = $products->map(function (Product $p) use ($overrides) { + $ov = $overrides->get($p->id); + return [ + 'product_id' => $p->id, + 'name' => $p->name, + 'image_url' => $p->image_url, + 'barcode' => $p->barcode, + 'global_price' => (float) $p->price, + 'global_member_price' => $p->member_price !== null ? (float) $p->member_price : null, + 'override_price' => $ov && $ov->price !== null ? (float) $ov->price : null, + 'override_member_price' => $ov && $ov->member_price !== null ? (float) $ov->member_price : null, + ]; + })->values(); + + return view('admin.basic-settings.machines.pricing', compact('machine', 'items')); + } + + /** + * 批次儲存機台專屬定價。 + * + * - price/member_price 皆空 → 刪除覆蓋,回到全域價。 + * - 任一有值 → 建立/更新覆蓋;會員價於 B012 下發時自動夾為不高於機台售價。 + * - 用 quiet 寫入避免逐筆觸發 observer 重複推送,最後統一推一次 B012。 + * - 商品須屬於該機台公司(跨租戶防護)。 + */ + public function updatePricing(Request $request, Machine $machine): \Illuminate\Http\JsonResponse + { + $validated = $request->validate([ + 'items' => 'required|array', + 'items.*.product_id' => 'required|integer|exists:products,id', + 'items.*.price' => 'nullable|numeric|min:1|max:99999999', + 'items.*.member_price' => 'nullable|numeric|min:1|max:99999999', + ]); + + $allowedProductIds = Product::where('company_id', $machine->company_id) + ->pluck('id') + ->flip(); + + DB::transaction(function () use ($validated, $machine, $allowedProductIds) { + foreach ($validated['items'] as $item) { + $productId = (int) $item['product_id']; + if (!$allowedProductIds->has($productId)) { + continue; + } + + $price = isset($item['price']) && $item['price'] !== '' ? (float) $item['price'] : null; + $memberPrice = isset($item['member_price']) && $item['member_price'] !== '' ? (float) $item['member_price'] : null; + + if ($price === null && $memberPrice === null) { + $machine->productPrices()->where('product_id', $productId)->delete(); + continue; + } + + $row = MachineProductPrice::firstOrNew([ + 'machine_id' => $machine->id, + 'product_id' => $productId, + ]); + $row->price = $price; + $row->member_price = $memberPrice; + $row->saveQuietly(); + } + }); + + // 統一推一次 B012 同步給該機台(覆蓋於請求當下即時套用,無需失效公司快取) + SendProductSyncCommandJob::dispatch($machine->id, __('Machine pricing updated'), Auth::id()); + + return response()->json([ + 'success' => true, + 'message' => __('Machine pricing saved and sync command pushed.'), + ]); + } + /** * 更新機台詳細參數 */ diff --git a/app/Http/Controllers/Api/V1/App/MachineController.php b/app/Http/Controllers/Api/V1/App/MachineController.php index 02eaebd..18996a4 100644 --- a/app/Http/Controllers/Api/V1/App/MachineController.php +++ b/app/Http/Controllers/Api/V1/App/MachineController.php @@ -394,8 +394,8 @@ class MachineController extends Controller { $machine = $request->get('machine'); - // Cache First: Get from cache or rebuild if missing - $payload = $catalogService->getPayload($machine->company_id); + // 公司基底目錄(快取) + 該機台專屬定價覆蓋(即時套用,見 getMachinePayload) + $payload = $catalogService->getMachinePayload($machine); return response()->json($payload); } diff --git a/app/Models/Machine/Machine.php b/app/Models/Machine/Machine.php index 369d505..e69ed23 100644 --- a/app/Models/Machine/Machine.php +++ b/app/Models/Machine/Machine.php @@ -54,6 +54,14 @@ class Machine extends Model ])) { app(\App\Services\Machine\MachineService::class)->syncMachineSlotMaxStock($machine); } + + // 機台轉移公司:不同公司商品 ID 體系不同,舊的機台專屬定價必須清空, + // 避免跨租戶價格錯位與資料污染。批次刪除不觸發 model 事件,故補推一次 + // B012 同步,讓機台重抓新公司目錄與(已清空的)定價。 + if ($machine->wasChanged('company_id')) { + $machine->productPrices()->delete(); + \App\Jobs\Product\SendProductSyncCommandJob::dispatch($machine->id, __('Machine company changed')); + } }); } @@ -401,6 +409,11 @@ class Machine extends Model return $this->hasMany(MachineSlot::class); } + public function productPrices() + { + return $this->hasMany(MachineProductPrice::class); + } + public function commands() { return $this->hasMany(RemoteCommand::class); diff --git a/app/Models/Machine/MachineProductPrice.php b/app/Models/Machine/MachineProductPrice.php new file mode 100644 index 0000000..a38864d --- /dev/null +++ b/app/Models/Machine/MachineProductPrice.php @@ -0,0 +1,58 @@ + 'decimal:2', + 'member_price' => 'decimal:2', + ]; + + protected static function booted(): void + { + static::saved(fn (self $row) => $row->dispatchSync()); + static::deleted(fn (self $row) => $row->dispatchSync()); + } + + /** + * 推送 B012 同步指令給該機台,讓它即時重抓最新定價。 + * 控制台/離線判定沿用 SendProductSyncCommandJob 既有邏輯。 + */ + protected function dispatchSync(): void + { + SendProductSyncCommandJob::dispatch($this->machine_id, __('Machine pricing updated')); + } + + public function machine() + { + return $this->belongsTo(Machine::class); + } + + public function product() + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Services/Product/ProductCatalogService.php b/app/Services/Product/ProductCatalogService.php index 204db9d..cfdc1e1 100644 --- a/app/Services/Product/ProductCatalogService.php +++ b/app/Services/Product/ProductCatalogService.php @@ -2,6 +2,8 @@ namespace App\Services\Product; +use App\Models\Machine\Machine; +use App\Models\Machine\MachineProductPrice; use App\Models\Product\Product; use App\Models\System\Company; use Illuminate\Support\Facades\Cache; @@ -76,6 +78,54 @@ class ProductCatalogService Cache::forget($this->getCacheKey($companyId)); } + /** + * 取得「該機台」的商品目錄:公司基底目錄(快取) + 機台專屬定價覆蓋。 + * + * 設計取捨:重的 i18n 目錄留在 per-company 快取;機台覆蓋於請求當下即時套用, + * 不另設機台快取。覆蓋只是一次 (machine_id) 的索引查詢,極輕量;且改價僅推「該台」 + * 重抓,不會造成多台群湧。如此完全避開「機台快取失效 / 公司基底變動連帶失效」的複雜度。 + * + * @param Machine $machine + * @return array + */ + public function getMachinePayload(Machine $machine): array + { + $payload = $this->getPayload($machine->company_id); + + $overrides = MachineProductPrice::where('machine_id', $machine->id) + ->get(['product_id', 'price', 'member_price']) + ->keyBy('product_id'); + + if ($overrides->isEmpty()) { + return $payload; + } + + // PHP 陣列為值複製,array_map 產生新陣列,不會污染 Cache 內的公司基底目錄。 + $payload['data'] = array_map(function ($item) use ($overrides) { + $override = $overrides->get((int) ($item['t060v00'] ?? 0)); + if (!$override) { + return $item; + } + + // 機台售價(t063v03=App machinePrice):有覆蓋價就用,否則維持全域售價基底。 + $sellingPrice = $override->price !== null + ? (float) $override->price + : (float) $item['t063v03']; + $item['t063v03'] = $sellingPrice; + + // 機台會員價(t060v30):有覆蓋就用,否則沿用全域會員價; + // 一律夾為「不高於機台售價」,避免會員買得比一般人貴。 + $memberPrice = $override->member_price !== null + ? (float) $override->member_price + : (float) $item['t060v30']; + $item['t060v30'] = min($memberPrice, $sellingPrice); + + return $item; + }, $payload['data']); + + return $payload; + } + /** * Map a Product model to the catalog format expected by machines. * diff --git a/database/migrations/2026_06_23_000001_create_machine_product_prices_table.php b/database/migrations/2026_06_23_000001_create_machine_product_prices_table.php new file mode 100644 index 0000000..817f9b5 --- /dev/null +++ b/database/migrations/2026_06_23_000001_create_machine_product_prices_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('machine_id')->constrained('machines')->onDelete('cascade'); + $table->foreignId('product_id')->constrained('products')->onDelete('cascade'); + $table->decimal('price', 10, 2)->nullable()->comment('機台專屬售價;NULL=用全域 products.price'); + $table->decimal('member_price', 10, 2)->nullable()->comment('機台專屬會員價;NULL=用全域 member_price(並夾為不高於機台售價)'); + $table->timestamps(); + + // 一台機台對同一商品只有一筆覆蓋價 + $table->unique(['machine_id', 'product_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('machine_product_prices'); + } +}; diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 0a69bc9..d52a5fa 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -1110,6 +1110,21 @@ "Machine Status List": "機台運行狀態列表", "Machine Stock": "機台庫存", "Machine Stock Movements": "機台庫存流水帳", + "Machine Pricing": "機台定價", + "Machine Price": "機台價", + "Leave blank to use the global product price. Member price will be capped at the machine selling price.": "留空則沿用全域商品價。會員價會自動夾為不高於機台售價。", + "No products on this machine yet": "此機台尚無商品", + "No products available for this company": "此公司尚無可販售商品", + "No matching products": "找不到符合的商品", + "overrides set": "項已設定機台價", + "Global": "全域價", + "Save & Sync": "儲存並同步", + "Failed to load pricing": "載入定價失敗", + "Saved": "已儲存", + "Save failed": "儲存失敗", + "Machine pricing saved and sync command pushed.": "機台定價已儲存並推送同步指令。", + "Machine pricing updated": "機台定價已更新", + "Machine company changed": "機台所屬公司已變更", "Machine System Settings": "機台系統設定", "Machine Utilization": "機台稼動率", "Machine created successfully.": "機台已成功建立。", diff --git a/resources/views/admin/basic-settings/machines/partials/tab-machines.blade.php b/resources/views/admin/basic-settings/machines/partials/tab-machines.blade.php index cea18f6..3fcdbf2 100644 --- a/resources/views/admin/basic-settings/machines/partials/tab-machines.blade.php +++ b/resources/views/admin/basic-settings/machines/partials/tab-machines.blade.php @@ -149,6 +149,13 @@ + + + + + + + + +@endsection + +@section('scripts') + +@endsection diff --git a/resources/views/admin/warehouses/machine-inventory.blade.php b/resources/views/admin/warehouses/machine-inventory.blade.php index 48e9832..805c739 100644 --- a/resources/views/admin/warehouses/machine-inventory.blade.php +++ b/resources/views/admin/warehouses/machine-inventory.blade.php @@ -783,6 +783,7 @@ + @endsection diff --git a/routes/web.php b/routes/web.php index bfa8bd6..641f514 100644 --- a/routes/web.php +++ b/routes/web.php @@ -293,6 +293,10 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix Route::patch('/{machine}/update-system-settings', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'updateSystemSettings'])->name('update-system-settings'); Route::post('/{machine}/sync-settings', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'syncSettings'])->name('sync-settings'); + // 機台專屬定價(獨立頁,列全公司目錄,可上貨前先定價) + Route::get('/{machine}/pricing', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'pricing'])->name('pricing'); + Route::post('/{machine}/pricing', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'updatePricing'])->name('pricing.update'); + // 地址轉座標 (Geocoding Proxy) Route::post('/geocode', [App\Http\Controllers\Admin\GeocodingController::class, 'resolve'])->name('geocode'); });