[PROMOTE] feat/per-machine-product-pricing → demo:每機台商品定價 (WIP) 上 demo 測試
This commit is contained in:
commit
5621cad1bf
@ -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)
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
58
app/Models/Machine/MachineProductPrice.php
Normal file
58
app/Models/Machine/MachineProductPrice.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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.": "機台已成功建立。",
|
||||
|
||||
@ -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
|
||||
|
||||
226
resources/views/admin/machines/pricing.blade.php
Normal file
226
resources/views/admin/machines/pricing.blade.php
Normal 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
|
||||
@ -783,6 +783,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user