[PROMOTE] feat/per-machine-product-pricing → demo:每機台商品定價 (WIP) 上 demo 測試

This commit is contained in:
sky121113 2026-06-23 15:55:14 +08:00
commit 5621cad1bf
11 changed files with 510 additions and 2 deletions

View File

@ -2,10 +2,15 @@
namespace App\Http\Controllers\Admin;
use App\Jobs\Product\SendProductSyncCommandJob;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineProductPrice;
use App\Models\Product\Product;
use App\Models\System\Company;
use App\Services\Machine\MachineService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class MachineController extends AdminController
@ -257,6 +262,91 @@ class MachineController extends AdminController
}
}
/**
* 機台專屬定價頁:列出該機台「所屬公司的完整商品目錄」(非僅貨道商品),
* 可在上貨前先把機台價/會員價訂好。未填=沿用全域 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.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.'),
]);
}
/**
* 取得機台統計數據 (AJAX)
*/

View File

@ -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);
}

View File

@ -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);

View File

@ -0,0 +1,58 @@
<?php
namespace App\Models\Machine;
use App\Jobs\Product\SendProductSyncCommandJob;
use App\Models\Product\Product;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* 機台專屬定價:特定機台 × 特定商品的價格覆蓋。
*
* - price / member_price NULL 代表沿用全域 products 價。
* - 任何新增/修改/刪除都會推送 B012 同步指令給「該台」機台,使其即時重抓目錄。
* 目錄覆蓋於 B012 請求當下即時套用(見 ProductCatalogService::getMachinePayload
* 不進公司目錄快取,故改價不需失效公司快取。
*/
class MachineProductPrice extends Model
{
use HasFactory;
protected $fillable = [
'machine_id',
'product_id',
'price',
'member_price',
];
protected $casts = [
'price' => '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);
}
}

View File

@ -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.
*

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 機台專屬定價:針對「特定機台 × 特定商品」覆蓋全域 products.price / member_price。
*
* - 無紀錄(或欄位為 NULL= 沿用全域商品價products.price / member_price
* - B012 下發時,命中的商品會把 t063v03(機台價) / t060v30(會員價) 換成此處的值。
* - 粒度為 (machine_id, product_id)App 端價格模型是 product-level同商品占多貨道共用同一價。
*/
public function up(): void
{
Schema::create('machine_product_prices', function (Blueprint $table) {
$table->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');
}
};

View File

@ -1121,6 +1121,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.": "機台已成功建立。",

View File

@ -720,6 +720,15 @@
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" />
</svg>
</button>
<a href="{{ route('admin.machines.pricing', $machine) }}"
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-amber-500 hover:bg-amber-500/5 dark:hover:bg-amber-500/10 border border-transparent hover:border-amber-500/20 transition-all duration-200"
title="{{ __('Machine Pricing') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</a>
</div>
</td>
</tr>
@ -854,6 +863,14 @@
</svg>
{{ __('Logs') }}
</button>
<a href="{{ route('admin.machines.pricing', $machine) }}"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-amber-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ __('Pricing') }}
</a>
</div>
</div>
@empty

View File

@ -0,0 +1,226 @@
@extends('layouts.admin')
@section('content')
<div class="space-y-6 pb-28"
x-data="machinePricing({{ Js::from($items) }}, '{{ route('admin.machines.pricing.update', $machine) }}')">
{{-- Header --}}
<div class="flex items-center gap-4">
<a href="{{ route('admin.machines.index') }}"
class="p-2.5 rounded-xl bg-white dark:bg-slate-900 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-all border border-slate-200/50 dark:border-slate-700/50 shadow-sm hover:shadow-md active:scale-95">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</a>
<div class="min-w-0">
<h1 class="text-2xl sm:text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight flex items-center gap-3">
<span class="p-2 rounded-xl bg-cyan-500/10 dark:bg-cyan-500/20">
<svg class="w-6 h-6 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</span>
{{ __('Machine Pricing') }}
</h1>
<div class="mt-2 flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest overflow-hidden">
<span class="font-mono text-cyan-600 dark:text-cyan-400 truncate">{{ $machine->serial_no }}</span>
<span class="opacity-50"></span>
<span class="truncate">{{ $machine->name }}</span>
</div>
</div>
</div>
<div class="luxury-card rounded-3xl p-6 sm:p-8 space-y-6">
{{-- Hint + Search --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<p class="text-sm text-slate-500 dark:text-slate-400 font-medium leading-relaxed max-w-2xl">
{{ __('Leave blank to use the global product price. Member price will be capped at the machine selling price.') }}
</p>
<div class="relative w-full sm:w-72 flex-shrink-0">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="h-4 w-4 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" x-model="search" class="py-2.5 pl-12 pr-6 block w-full luxury-input"
placeholder="{{ __('Search products...') }}">
</div>
</div>
{{-- Empty (no products in company) --}}
<template x-if="items.length === 0">
<div class="text-center py-20 text-slate-400 font-bold uppercase tracking-widest text-sm">
{{ __('No products available for this company') }}
</div>
</template>
{{-- Table --}}
<div x-show="items.length > 0" class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-200/60 dark:border-slate-700/50">
<th class="text-left py-3 px-3">{{ __('Product') }}</th>
<th class="text-center py-3 px-3 w-40">{{ __('Machine Price') }}</th>
<th class="text-center py-3 px-3 w-40">{{ __('Member Price') }}</th>
</tr>
</thead>
<tbody>
<template x-for="item in filteredItems" :key="item.product_id">
<tr class="border-b border-slate-100 dark:border-slate-800/60 hover:bg-slate-50 dark:hover:bg-slate-800/40 transition-colors">
{{-- Product --}}
<td class="py-3 px-3">
<div class="flex items-center gap-3 min-w-0">
<div class="w-11 h-11 rounded-xl bg-slate-100 dark:bg-slate-800 overflow-hidden flex-shrink-0 flex items-center justify-center text-slate-300">
<template x-if="item.image_url">
<img :src="item.image_url" class="w-full h-full object-cover">
</template>
<template x-if="!item.image_url">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
</template>
</div>
<div class="min-w-0">
<div class="font-black text-slate-800 dark:text-white truncate" x-text="item.name"></div>
<div class="text-[11px] font-bold text-slate-400 uppercase tracking-widest">
{{ __('Global') }}: $<span x-text="Math.floor(item.global_price)"></span>
<template x-if="item.global_member_price !== null">
<span> · {{ __('Member') }} $<span x-text="Math.floor(item.global_member_price)"></span></span>
</template>
</div>
</div>
</div>
</td>
{{-- Machine Price --}}
<td class="py-3 px-3">
<div class="flex items-center h-12 rounded-xl border border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all overflow-hidden">
<button type="button" @click="bump(item, 'override_price', -1)" class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" /></svg>
</button>
<div class="flex-1 min-w-[56px]">
<input type="number" min="1" step="1" inputmode="numeric"
x-model="item.override_price"
:placeholder="Math.floor(item.global_price)"
class="w-full bg-transparent border-none text-center font-black text-slate-800 dark:text-white focus:ring-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
</div>
<button type="button" @click="bump(item, 'override_price', 1)" class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
</button>
</div>
</td>
{{-- Member Price --}}
<td class="py-3 px-3">
<div class="flex items-center h-12 rounded-xl border border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all overflow-hidden">
<button type="button" @click="bump(item, 'override_member_price', -1)" class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" /></svg>
</button>
<div class="flex-1 min-w-[56px]">
<input type="number" min="1" step="1" inputmode="numeric"
x-model="item.override_member_price"
:placeholder="item.global_member_price !== null ? Math.floor(item.global_member_price) : '—'"
class="w-full bg-transparent border-none text-center font-black text-slate-800 dark:text-white focus:ring-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
</div>
<button type="button" @click="bump(item, 'override_member_price', 1)" class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
</button>
</div>
</td>
</tr>
</template>
<template x-if="items.length > 0 && filteredItems.length === 0">
<tr><td colspan="3" class="text-center py-12 text-slate-400 font-bold uppercase tracking-widest text-sm">{{ __('No matching products') }}</td></tr>
</template>
</tbody>
</table>
</div>
</div>
{{-- Sticky Save Bar --}}
<div class="fixed bottom-0 inset-x-0 sm:left-auto sm:right-8 sm:bottom-8 z-40 flex justify-end px-4 sm:px-0">
<div class="w-full sm:w-auto bg-white/90 dark:bg-slate-900/90 backdrop-blur-xl border border-slate-200 dark:border-white/10 rounded-2xl shadow-2xl px-5 py-4 flex items-center justify-end gap-3">
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest mr-auto sm:mr-2" x-text="overrideCount + ' {{ __('overrides set') }}'"></span>
<a href="{{ route('admin.machines.index') }}"
class="px-5 py-2.5 rounded-xl text-sm font-black text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 uppercase tracking-widest transition-colors">
{{ __('Cancel') }}
</a>
<button type="button" @click="save()" :disabled="saving || items.length === 0"
class="px-6 py-2.5 rounded-xl text-sm font-black bg-cyan-500 text-white shadow-lg shadow-cyan-500/25 hover:shadow-cyan-500/40 hover:-translate-y-0.5 active:translate-y-0 transition-all uppercase tracking-widest disabled:opacity-40 disabled:pointer-events-none flex items-center gap-2">
<template x-if="saving">
<span class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></span>
</template>
{{ __('Save & Sync') }}
</button>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('machinePricing', (initialItems, saveUrl) => ({
// 空字串代表「沿用全域價」,以 placeholder 顯示全域價作為提示
items: initialItems.map(p => ({
...p,
override_price: p.override_price !== null ? p.override_price : '',
override_member_price: p.override_member_price !== null ? p.override_member_price : '',
})),
saveUrl,
search: '',
saving: false,
get filteredItems() {
const q = this.search.trim().toLowerCase();
if (!q) return this.items;
return this.items.filter(i => (i.name || '').toLowerCase().includes(q) || String(i.barcode || '').toLowerCase().includes(q));
},
// +/- 步進:欄位為空(沿用全域)時,從全域價起跳;最低 1與後端 min:1 一致)
bump(item, field, delta) {
const base = field === 'override_member_price'
? (item.global_member_price !== null ? item.global_member_price : item.global_price)
: item.global_price;
const cur = (item[field] === '' || item[field] === null) ? base : parseInt(item[field]);
item[field] = Math.max(1, (parseInt(cur) || base) + delta);
},
get overrideCount() {
return this.items.filter(i =>
(i.override_price !== '' && i.override_price !== null) ||
(i.override_member_price !== '' && i.override_member_price !== null)
).length;
},
async save() {
if (this.saving || this.items.length === 0) return;
this.saving = true;
const payload = this.items.map(p => ({
product_id: p.product_id,
price: (p.override_price === '' || p.override_price === null) ? null : p.override_price,
member_price: (p.override_member_price === '' || p.override_member_price === null) ? null : p.override_member_price,
}));
try {
const res = await fetch(this.saveUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
body: JSON.stringify({ items: payload }),
});
const data = await res.json();
if (res.ok && data.success) {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message || '{{ __('Saved') }}', type: 'success' } }));
} else {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message || '{{ __('Save failed') }}', type: 'error' } }));
}
} catch (e) {
console.error('Pricing save error:', e);
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Save failed') }}', type: 'error' } }));
} finally {
this.saving = false;
}
}
}));
});
</script>
@endsection

View File

@ -783,6 +783,7 @@
</div>
</div>
</div>
</div>
@endsection

View File

@ -68,6 +68,9 @@ 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::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::get('/{machine}/logs-ajax', [App\Http\Controllers\Admin\MachineController::class, 'logsAjax'])->name('logs-ajax');
Route::get('/{machine}/temperature-ajax', [App\Http\Controllers\Admin\MachineController::class, 'temperatureAjax'])->name('temperature-ajax');
Route::get('/{machine}/ambient-temperature-ajax', [App\Http\Controllers\Admin\MachineController::class, 'ambientTemperatureAjax'])->name('ambient-temperature-ajax');