[RELEASE] feat/per-machine-product-pricing → main:每機台商品定價 + 補貨單/機台列表 RWD 修正上線

This commit is contained in:
sky121113 2026-06-23 16:36:31 +08:00
commit fcd0588686
14 changed files with 574 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

@ -757,6 +757,7 @@
"Failed to get invoice print URL": "Failed to get invoice print URL",
"Failed to load permissions": "Failed to load permissions",
"Failed to load preview": "Failed to load preview",
"Failed to load pricing": "Failed to load pricing",
"Failed to load tab content": "Failed to load tab content",
"Failed to resolve issues.": "Failed to resolve issues.",
"Failed to save permissions.": "Failed to save permissions.",
@ -889,6 +890,7 @@
"Generate Replenishment Order": "Generate Replenishment Order",
"Generate and manage one-time pickup codes for customers": "Generate and manage one-time pickup codes for customers",
"Gift Definitions": "Gift Definitions",
"Global": "Global",
"Global roles accessible by all administrators.": "Global roles accessible by all administrators.",
"Go Authorize": "Go Authorize",
"Go Back": "Go Back",
@ -1009,6 +1011,7 @@
"Latitude": "Latitude",
"Lease": "Lease",
"Leave blank to deploy immediately, or set a future time to schedule a background release.": "Leave blank to deploy immediately, or set a future time to schedule a background release.",
"Leave blank to use the global product price. Member price will be capped at the machine selling price.": "Leave blank to use the global product price. Member price will be capped at the machine selling price.",
"Leave empty for permanent code": "Leave empty for permanent code",
"Leave empty or 0 for permanent code": "Leave empty or 0 for permanent code",
"Leave empty to inherit company settings": "Leave empty to inherit company settings",
@ -1094,6 +1097,8 @@
"Machine Model Settings": "Machine Model Settings",
"Machine Name": "Machine Name",
"Machine Permissions": "Machine Permissions",
"Machine Price": "Machine Price",
"Machine Pricing": "Machine Pricing",
"Machine Reboot": "Machine Reboot",
"Machine Registry": "Machine Registry",
"Machine Replenishment": "Machine Replenishment",
@ -1112,6 +1117,7 @@
"Machine Stock Movements": "Machine Stock Movements",
"Machine System Settings": "Machine System Settings",
"Machine Utilization": "Machine Utilization",
"Machine company changed": "Machine company changed",
"Machine created successfully.": "Machine created successfully.",
"Machine deleted successfully.": "Machine deleted successfully.",
"Machine has reconnected to the server and resumed normal heartbeat.": "Machine has reconnected to the server and resumed normal heartbeat.",
@ -1124,6 +1130,8 @@
"Machine normal": "Machine normal",
"Machine not found": "Machine not found",
"Machine permissions updated successfully.": "Machine permissions updated successfully.",
"Machine pricing saved and sync command pushed.": "Machine pricing saved and sync command pushed.",
"Machine pricing updated": "Machine pricing updated",
"Machine settings updated successfully.": "Machine settings updated successfully.",
"Machine temperature exceeds limit": "Machine temperature exceeds limit, purchase suspended",
"Machine to Warehouse": "Machine to Warehouse",
@ -1325,6 +1333,7 @@
"No maintenance records found": "No maintenance records found",
"No matching logs found": "No matching logs found",
"No matching machines": "No matching machines",
"No matching products": "No matching products",
"No materials available": "No materials available",
"No movement records found": "No movement records found",
"No movements found": "No movements found",
@ -1334,9 +1343,11 @@
"No pickup codes found": "No pickup codes found",
"No product data matching search criteria": "No product data matching search criteria",
"No product record found": "No product record found",
"No products available for this company": "No products available for this company",
"No products found": "No products found",
"No products found in this warehouse": "No products found in this warehouse",
"No products found matching your criteria.": "No products found matching your criteria.",
"No products on this machine yet": "No products on this machine yet",
"No records found": "No records found",
"No related detail found": "No related detail found",
"No remark": "No remark",
@ -1806,6 +1817,7 @@
"Sales Today": "Sales Today",
"Sales revenue minus product cost net value": "Sales revenue minus product cost net value",
"Save": "Save",
"Save & Sync": "Save & Sync",
"Save Changes": "Save Changes",
"Save Config": "Save Config",
"Save Material": "Save Material",
@ -1813,6 +1825,8 @@
"Save Settings": "Save Settings",
"Save Version": "Save Version",
"Save error:": "Save error:",
"Save failed": "Save failed",
"Saved": "Saved",
"Saved.": "Saved.",
"Saving...": "Saving...",
"Scale level and access control": "Scale level and access control",
@ -2562,6 +2576,7 @@
"of": "of",
"of items": "items",
"orders": "orders",
"overrides set": "overrides set",
"pending": "Pending",
"permissions": "Permission Settings",
"permissions.accounts": "Account Management",

View File

@ -757,6 +757,7 @@
"Failed to get invoice print URL": "領収書印刷URLの取得に失敗しました",
"Failed to load permissions": "権限の読み込みに失敗しました",
"Failed to load preview": "プレビューの読み込みに失敗しました",
"Failed to load pricing": "価格設定の読み込みに失敗しました",
"Failed to load tab content": "タブ内容の読み込みに失敗しました",
"Failed to resolve issues.": "異常の解消に失敗しました。",
"Failed to save permissions.": "権限の保存に失敗しました。",
@ -889,6 +890,7 @@
"Generate Replenishment Order": "補充伝票生成",
"Generate and manage one-time pickup codes for customers": "顧客用のワンタイム受取コードを生成・管理",
"Gift Definitions": "ギフト設定",
"Global": "グローバル価格",
"Global roles accessible by all administrators.": "全管理者がアクセス可能なグローバルロール。",
"Go Authorize": "先に授権する",
"Go Back": "戻る",
@ -1009,6 +1011,7 @@
"Latitude": "緯度",
"Lease": "リース",
"Leave blank to deploy immediately, or set a future time to schedule a background release.": "空欄のままにすると即時配信されます。未来の日時を指定すると背景で自動予約配信されます。",
"Leave blank to use the global product price. Member price will be capped at the machine selling price.": "空欄の場合はグローバル商品価格を使用します。会員価格はマシン販売価格を上限に自動調整されます。",
"Leave empty for permanent code": "空欄で無期限コードになります",
"Leave empty or 0 for permanent code": "空欄または0で無期限コードになります",
"Leave empty to inherit company settings": "空白のままにすると会社のグローバル設定を継承します",
@ -1094,6 +1097,8 @@
"Machine Model Settings": "機器モデル設定",
"Machine Name": "機器名",
"Machine Permissions": "機器権限",
"Machine Price": "マシン価格",
"Machine Pricing": "マシン価格設定",
"Machine Reboot": "機器アプリ再起動",
"Machine Registry": "機器リスト",
"Machine Replenishment": "機器補充伝票",
@ -1112,6 +1117,7 @@
"Machine Stock Movements": "機器在庫変動履歴",
"Machine System Settings": "機器システム設定",
"Machine Utilization": "機器稼働率",
"Machine company changed": "マシンの所属会社が変更されました",
"Machine created successfully.": "機器が正常に作成されました。",
"Machine deleted successfully.": "機器が正常に削除されました。",
"Machine has reconnected to the server and resumed normal heartbeat.": "マシンがサーバーに再接続し、通常のハートビートを再開しました。",
@ -1124,6 +1130,8 @@
"Machine normal": "デバイス正常",
"Machine not found": "機器が見つかりません",
"Machine permissions updated successfully.": "機器権限が正常に更新されました。",
"Machine pricing saved and sync command pushed.": "マシン価格を保存し、同期コマンドを送信しました。",
"Machine pricing updated": "マシン価格を更新しました",
"Machine settings updated successfully.": "機器設定が正常に更新されました。",
"Machine temperature exceeds limit": "設定温度超過のため購入一時停止",
"Machine to Warehouse": "機器から倉庫",
@ -1325,6 +1333,7 @@
"No maintenance records found": "メンテナンス記録が見つかりません",
"No matching logs found": "一致するログが見つかりません",
"No matching machines": "一致する機器がありません",
"No matching products": "一致する商品がありません",
"No materials available": "利用可能な素材がありません",
"No movement records found": "変動記録が見つかりません",
"No movements found": "変動が見つかりません",
@ -1334,9 +1343,11 @@
"No pickup codes found": "受取コードが見つかりません",
"No product data matching search criteria": "検索条件に一致する商品データはありません",
"No product record found": "商品記録が見つかりません",
"No products available for this company": "この会社で販売可能な商品がありません",
"No products found": "商品が見つかりません",
"No products found in this warehouse": "この倉庫に商品は見つかりません",
"No products found matching your criteria.": "条件に一致する商品が見つかりません。",
"No products on this machine yet": "このマシンにはまだ商品がありません",
"No records found": "記録が見つかりません",
"No related detail found": "関連する詳細が見つかりません",
"No remark": "備考なし",
@ -1806,6 +1817,7 @@
"Sales Today": "本日の販売数",
"Sales revenue minus product cost net value": "販売額から商品コストを差し引いた純利益",
"Save": "保存",
"Save & Sync": "保存して同期",
"Save Changes": "変更を保存",
"Save Config": "設定を保存",
"Save Material": "素材を保存",
@ -1813,6 +1825,8 @@
"Save Settings": "設定を保存",
"Save Version": "Save Version",
"Save error:": "保存エラー:",
"Save failed": "保存に失敗しました",
"Saved": "保存しました",
"Saved.": "保存しました。",
"Saving...": "保存中...",
"Scale level and access control": "スケールレベルとアクセス制御",
@ -2562,6 +2576,7 @@
"of": "/全",
"of items": "件中",
"orders": "件",
"overrides set": "件のマシン価格を設定済み",
"pending": "機器受取待機中",
"permissions": "権限設定",
"permissions.accounts": "アカウント管理",

View File

@ -757,6 +757,7 @@
"Failed to get invoice print URL": "取得發票列印網址失敗",
"Failed to load permissions": "載入權限失敗",
"Failed to load preview": "載入預覽失敗",
"Failed to load pricing": "載入定價失敗",
"Failed to load tab content": "載入分頁內容失敗",
"Failed to resolve issues.": "排除異常失敗。",
"Failed to save permissions.": "無法儲存權限設定。",
@ -889,6 +890,7 @@
"Generate Replenishment Order": "產生補貨單",
"Generate and manage one-time pickup codes for customers": "產生並管理客戶的一次性取貨碼",
"Gift Definitions": "禮品設定",
"Global": "全域價",
"Global roles accessible by all administrators.": "適用於所有管理者的全域角色。",
"Go Authorize": "前往授權",
"Go Back": "返回",
@ -1009,6 +1011,7 @@
"Latitude": "緯度",
"Lease": "租賃",
"Leave blank to deploy immediately, or set a future time to schedule a background release.": "留空將立即部署更新,或設定未來時間由背景自動排程發布。",
"Leave blank to use the global product price. Member price will be capped at the machine selling price.": "留空則沿用全域商品價。會員價會自動夾為不高於機台售價。",
"Leave empty for permanent code": "留空表示永久有效",
"Leave empty or 0 for permanent code": "留空或輸入 0 則為永久代碼",
"Leave empty to inherit company settings": "留空則繼承公司全域設定",
@ -1094,6 +1097,8 @@
"Machine Model Settings": "機台型號設定",
"Machine Name": "機台名稱",
"Machine Permissions": "機台權限",
"Machine Price": "機台價",
"Machine Pricing": "機台定價",
"Machine Reboot": "機台 APP 重啟",
"Machine Registry": "機台清冊",
"Machine Replenishment": "機台補貨單",
@ -1112,6 +1117,7 @@
"Machine Stock Movements": "機台庫存流水帳",
"Machine System Settings": "機台系統設定",
"Machine Utilization": "機台稼動率",
"Machine company changed": "機台所屬公司已變更",
"Machine created successfully.": "機台已成功建立。",
"Machine deleted successfully.": "機台已成功刪除。",
"Machine has reconnected to the server and resumed normal heartbeat.": "機台已重新連線至伺服器並恢復正常心跳。",
@ -1124,6 +1130,8 @@
"Machine normal": "機台正常",
"Machine not found": "找不到機台",
"Machine permissions updated successfully.": "機台權限已成功更新。",
"Machine pricing saved and sync command pushed.": "機台定價已儲存並推送同步指令。",
"Machine pricing updated": "機台定價已更新",
"Machine settings updated successfully.": "機台設定已成功更新。",
"Machine temperature exceeds limit": "機器超過設定溫度,暫停購買",
"Machine to Warehouse": "機台對倉庫",
@ -1325,6 +1333,7 @@
"No maintenance records found": "找不到維修紀錄",
"No matching logs found": "找不到符合條件的日誌",
"No matching machines": "查無匹配機台",
"No matching products": "找不到符合的商品",
"No materials available": "沒有可用的素材",
"No movement records found": "查無異動紀錄",
"No movements found": "尚無異動紀錄",
@ -1334,9 +1343,11 @@
"No pickup codes found": "找不到取貨碼",
"No product data matching search criteria": "沒有符合搜尋條件的商品資料",
"No product record found": "無商品紀錄",
"No products available for this company": "此公司尚無可販售商品",
"No products found": "未找到商品",
"No products found in this warehouse": "此倉庫目前無商品庫存",
"No products found matching your criteria.": "找不到符合條件的商品。",
"No products on this machine yet": "此機台尚無商品",
"No records found": "未找到相關紀錄",
"No related detail found": "無關聯詳細資料",
"No remark": "尚無備註",
@ -1806,6 +1817,7 @@
"Sales Today": "今日銷售",
"Sales revenue minus product cost net value": "銷售額扣除商品成本淨值",
"Save": "儲存",
"Save & Sync": "儲存並同步",
"Save Changes": "儲存變更",
"Save Config": "儲存配置",
"Save Material": "儲存素材",
@ -1813,6 +1825,8 @@
"Save Settings": "儲存設定",
"Save Version": "儲存版本",
"Save error:": "儲存錯誤:",
"Save failed": "儲存失敗",
"Saved": "已儲存",
"Saved.": "已儲存",
"Saving...": "儲存中...",
"Scale level and access control": "層級與存取控制",
@ -2562,6 +2576,7 @@
"of": "/共",
"of items": "筆項目",
"orders": "筆",
"overrides set": "項已設定機台價",
"pending": "等待機台領取",
"permissions": "權限設定",
"permissions.accounts": "帳號管理",

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>
@ -821,6 +830,39 @@
<p class="text-[9px] font-bold text-slate-400 mt-1">{{ $machine->latest_status_log_time->diffForHumans() }}</p>
@endif
</div>
<div>
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Hardware Status') }}</p>
@php $hStatus = $machine->hardware_status; @endphp
<div class="flex flex-col gap-1.5">
{{-- 下位機 --}}
<div class="flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full {{
$hStatus === 'error' ? 'bg-rose-500 animate-pulse' :
($hStatus === 'warning' ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500/50')
}}"></span>
<span class="text-xs font-black {{
$hStatus === 'error' ? 'text-rose-500' :
($hStatus === 'warning' ? 'text-amber-500' : 'text-slate-400')
}} uppercase tracking-widest">{{ __('Sub-machine') }}</span>
</div>
{{-- 刷卡機 (信用卡/電子票證/手機支付共用同一台):僅基礎版且啟用刷卡機才顯示 --}}
@if($machine->show_card_terminal)
@php $ctStatus = $machine->card_terminal_status; @endphp
<div class="flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full {{
$ctStatus === 'error' ? 'bg-rose-500 animate-pulse' :
($ctStatus === 'warning' ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500/50')
}}"></span>
<span class="text-xs font-black {{
$ctStatus === 'error' ? 'text-rose-500' :
($ctStatus === 'warning' ? 'text-amber-500' : 'text-slate-400')
}} uppercase tracking-widest">{{ __('Card Terminal') }}</span>
</div>
@endif
</div>
</div>
<div class="col-span-2">
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
@ -854,6 +896,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

@ -104,6 +104,7 @@
'logs' => __('Machine Logs'),
'permissions' => __('Machine Permissions'),
'utilization' => __('Utilization Rate'),
'pricing' => __('Machine Pricing'),
'expiry' => __('Expiry Management'),
'maintenance' => __('Maintenance Records'),
'ui-elements' => __('UI Elements'),

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