[FIX] 整合機台效期管理功能並優化 UI 比例
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m2s
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m2s
- 修正 Alpine.js 作用域問題,恢復效期編輯彈窗功能 - 整合機台日誌與效期管理至主列表頁 (Index) - 優化大螢幕貨道格線佈局,解決日期折行問題 - 縮小彈窗字體與內距,調整為極簡奢華風 UI - 新增貨道效期與批號欄位之 Migration 與模型關聯 - 補齊中、英、日三語系翻譯檔
This commit is contained in:
@@ -9,11 +9,13 @@ use Illuminate\View\View;
|
||||
class MachineController extends AdminController
|
||||
{
|
||||
/**
|
||||
* 顯示所有機台列表
|
||||
* 顯示所有機台列表或效期管理
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$tab = $request->input('tab', 'list');
|
||||
$per_page = $tab === 'list' ? $request->input('per_page', 10) : $request->input('per_page', 12);
|
||||
|
||||
$query = Machine::query();
|
||||
|
||||
// 搜尋:名稱或序號
|
||||
@@ -24,14 +26,33 @@ class MachineController extends AdminController
|
||||
});
|
||||
}
|
||||
|
||||
$machines = $query->when($request->status, function ($query, $status) {
|
||||
return $query->where('status', $status);
|
||||
})
|
||||
->latest()
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
if ($tab === 'list') {
|
||||
$machines = $query->when($request->status, function ($query, $status) {
|
||||
return $query->where('status', $status);
|
||||
})
|
||||
->latest()
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.machines.index', compact('machines'));
|
||||
return view('admin.machines.index', compact('machines', 'tab'));
|
||||
} else {
|
||||
// 效期管理模式:獲取機台及其貨道統計
|
||||
$machines = $query->withCount(['slots as total_slots'])
|
||||
->withCount(['slots as expired_count' => function ($q) {
|
||||
$q->where('expiry_date', '<', now()->toDateString());
|
||||
}])
|
||||
->withCount(['slots as pending_count' => function ($q) {
|
||||
$q->whereNull('expiry_date');
|
||||
}])
|
||||
->withCount(['slots as warning_count' => function ($q) {
|
||||
$q->whereBetween('expiry_date', [now()->toDateString(), now()->addDays(7)->toDateString()]);
|
||||
}])
|
||||
->latest()
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.machines.index', compact('machines', 'tab'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,25 +67,38 @@ class MachineController extends AdminController
|
||||
return view('admin.machines.show', compact('machine'));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 顯示所有機台日誌列表
|
||||
* AJAX: 取得機台抽屜面板所需的歷程日誌
|
||||
*/
|
||||
public function logs(Request $request): View
|
||||
public function logsAjax(Request $request, Machine $machine)
|
||||
{
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$logs = \App\Models\Machine\MachineLog::with('machine')
|
||||
$per_page = $request->input('per_page', 20);
|
||||
|
||||
$startDate = $request->get('start_date', now()->format('Y-m-d'));
|
||||
$endDate = $request->get('end_date', now()->format('Y-m-d'));
|
||||
|
||||
$logs = $machine->logs()
|
||||
->when($request->level, function ($query, $level) {
|
||||
return $query->where('level', $level);
|
||||
})
|
||||
->when($request->machine_id, function ($query, $machineId) {
|
||||
return $query->where('machine_id', $machineId);
|
||||
->whereDate('created_at', '>=', $startDate)
|
||||
->whereDate('created_at', '<=', $endDate)
|
||||
->when($request->type, function ($query, $type) {
|
||||
return $query->where('type', $type);
|
||||
})
|
||||
->latest()
|
||||
->paginate($per_page)->withQueryString();
|
||||
->paginate($per_page);
|
||||
|
||||
$machines = Machine::select('id', 'name')->get();
|
||||
|
||||
return view('admin.machines.logs', compact('logs', 'machines'));
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $logs->items(),
|
||||
'pagination' => [
|
||||
'total' => $logs->total(),
|
||||
'current_page' => $logs->currentPage(),
|
||||
'last_page' => $logs->lastPage(),
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -132,19 +166,77 @@ class MachineController extends AdminController
|
||||
}
|
||||
|
||||
/**
|
||||
* 機台使用率統計 (開發中)
|
||||
* 機台使用率統計
|
||||
*/
|
||||
public function utilization(Request $request): View
|
||||
{
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
// 取得當前使用者有權限的所有機台 (已透過 Global Scope 過濾)
|
||||
$machines = Machine::all();
|
||||
|
||||
return view('admin.machines.utilization', [
|
||||
'machines' => $machines
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 機台到期管理 (開發中)
|
||||
* AJAX: 取得機台所有貨道資訊 (供效期管理視覺化圖表使用)
|
||||
*/
|
||||
public function expiry(Request $request): View
|
||||
public function slotsAjax(Machine $machine)
|
||||
{
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
$slots = $machine->slots()->with('product:id,name,image')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'machine' => $machine->only(['id', 'name', 'serial_no']),
|
||||
'slots' => $slots
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 更新貨道效期
|
||||
*/
|
||||
public function updateSlotExpiry(Request $request, Machine $machine)
|
||||
{
|
||||
$request->validate([
|
||||
'slot_no' => 'required|integer',
|
||||
'expiry_date' => 'nullable|date',
|
||||
'apply_all_same_product' => 'boolean'
|
||||
]);
|
||||
|
||||
$slotNo = $request->slot_no;
|
||||
$expiryDate = $request->expiry_date;
|
||||
$applyAll = $request->apply_all_same_product ?? false;
|
||||
|
||||
$slot = $machine->slots()->where('slot_no', $slotNo)->firstOrFail();
|
||||
$slot->update(['expiry_date' => $expiryDate]);
|
||||
|
||||
if ($applyAll && $slot->product_id) {
|
||||
$machine->slots()
|
||||
->where('product_id', $slot->product_id)
|
||||
->update(['expiry_date' => $expiryDate]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Expiry updated successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得機台統計數據 (AJAX)
|
||||
*/
|
||||
public function utilizationData(int $id, Request $request)
|
||||
{
|
||||
$machine = Machine::findOrFail($id);
|
||||
$date = $request->get('date', now()->toDateString());
|
||||
|
||||
$service = app(\App\Services\Machine\MachineService::class);
|
||||
$stats = $service->getUtilizationStats($machine, $date);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,25 @@ class MachineController extends Controller
|
||||
], 202); // 202 Accepted
|
||||
}
|
||||
|
||||
/**
|
||||
* B018: Record Machine Restock/Setup Report (Asynchronous)
|
||||
*/
|
||||
public function recordRestock(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$data['serial_no'] = $machine->serial_no;
|
||||
|
||||
\App\Jobs\Machine\ProcessRestockReport::dispatch($data);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Restock report accepted',
|
||||
'status' => '49'
|
||||
], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* B017: Get Slot Info & Stock (Synchronous)
|
||||
*/
|
||||
|
||||
37
app/Jobs/Machine/ProcessRestockReport.php
Normal file
37
app/Jobs/Machine/ProcessRestockReport.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Machine;
|
||||
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class ProcessRestockReport implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
protected $data;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(\App\Services\Machine\MachineService $machineService): void
|
||||
{
|
||||
$serialNo = $this->data['serial_no'] ?? null;
|
||||
$slotsData = $this->data['slots'] ?? [];
|
||||
|
||||
if (!$serialNo) return;
|
||||
|
||||
$machine = \App\Models\Machine\Machine::where('serial_no', $serialNo)->first();
|
||||
if ($machine) {
|
||||
$machineService->syncSlots($machine, $slotsData);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@ class Machine extends Model
|
||||
// 權限隔離:一般帳號登入時只能看到自己被分配的機台
|
||||
static::addGlobalScope('machine_access', function (\Illuminate\Database\Eloquent\Builder $builder) {
|
||||
$user = auth()->user();
|
||||
// 如果是在 Console、或是系統管理員、或是租戶的「管理員」角色,則不限制 (可看該公司所有機台)
|
||||
if (app()->runningInConsole() || !$user || $user->isSystemAdmin() || $user->hasRole('管理員') || $user->hasRole('super-admin')) {
|
||||
// 如果是在 Console、或是系統管理員,則不限制 (可看所有機台)
|
||||
if (app()->runningInConsole() || !$user || $user->isSystemAdmin()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,6 +101,11 @@ class Machine extends Model
|
||||
return $this->hasMany(MachineLog::class);
|
||||
}
|
||||
|
||||
public function slots()
|
||||
{
|
||||
return $this->hasMany(MachineSlot::class);
|
||||
}
|
||||
|
||||
public function machineModel()
|
||||
{
|
||||
return $this->belongsTo(MachineModel::class);
|
||||
@@ -121,6 +126,38 @@ class Machine extends Model
|
||||
return $this->belongsTo(\App\Models\System\User::class, 'updater_id');
|
||||
}
|
||||
|
||||
public const PAGE_STATUSES = [
|
||||
'0' => 'Offline',
|
||||
'1' => 'Home Page',
|
||||
'2' => 'Vending Page',
|
||||
'3' => 'Admin Page',
|
||||
'4' => 'Replenishment Page',
|
||||
'5' => 'Tutorial Page',
|
||||
'60' => 'Purchasing',
|
||||
'61' => 'Locked Page',
|
||||
'62' => 'Dispense Failed',
|
||||
'301' => 'Slot Test',
|
||||
'302' => 'Slot Test',
|
||||
'401' => 'Payment Selection',
|
||||
'402' => 'Waiting for Payment',
|
||||
'403' => 'Dispensing',
|
||||
'404' => 'Receipt Printing',
|
||||
'601' => 'Pass Code',
|
||||
'602' => 'Pickup Code',
|
||||
'603' => 'Message Display',
|
||||
'604' => 'Cancel Purchase',
|
||||
'605' => 'Purchase Finished',
|
||||
'611' => 'Welcome Gift Status',
|
||||
'612' => 'Dispense Failed',
|
||||
];
|
||||
|
||||
public function getCurrentPageLabelAttribute(): string
|
||||
{
|
||||
$code = (string) $this->current_page;
|
||||
$label = self::PAGE_STATUSES[$code] ?? $code;
|
||||
return __($label);
|
||||
}
|
||||
|
||||
public function users()
|
||||
{
|
||||
return $this->belongsToMany(\App\Models\System\User::class);
|
||||
|
||||
@@ -12,8 +12,10 @@ class MachineLog extends Model
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
'level',
|
||||
'type',
|
||||
'message',
|
||||
'context',
|
||||
];
|
||||
|
||||
@@ -14,17 +14,17 @@ class MachineSlot extends Model
|
||||
'machine_id',
|
||||
'product_id',
|
||||
'slot_no',
|
||||
'slot_name',
|
||||
'capacity',
|
||||
'max_stock',
|
||||
'stock',
|
||||
'price',
|
||||
'status',
|
||||
'last_restocked_at',
|
||||
'expiry_date',
|
||||
'batch_no',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'last_restocked_at' => 'datetime',
|
||||
'expiry_date' => 'date:Y-m-d',
|
||||
];
|
||||
|
||||
public function machine()
|
||||
|
||||
@@ -21,12 +21,20 @@ class MachineService
|
||||
return DB::transaction(function () use ($serialNo, $data) {
|
||||
$machine = Machine::where('serial_no', $serialNo)->firstOrFail();
|
||||
|
||||
// 參數相容性處理 (Mapping legacy fields to new fields)
|
||||
$temperature = $data['temperature'] ?? $machine->temperature;
|
||||
$currentPage = $data['current_page'] ?? $data['M_Stus2'] ?? $machine->current_page;
|
||||
$doorStatus = $data['door_status'] ?? $data['door'] ?? $machine->door_status;
|
||||
$firmwareVersion = $data['firmware_version'] ?? $data['M_Ver'] ?? $machine->firmware_version;
|
||||
$model = $data['model'] ?? $data['M_Stus'] ?? $machine->model;
|
||||
|
||||
$updateData = [
|
||||
'status' => 'online',
|
||||
'temperature' => $data['temperature'] ?? $machine->temperature,
|
||||
'current_page' => $data['current_page'] ?? $machine->current_page,
|
||||
'door_status' => $data['door_status'] ?? $machine->door_status,
|
||||
'firmware_version' => $data['firmware_version'] ?? $machine->firmware_version,
|
||||
'temperature' => $temperature,
|
||||
'current_page' => $currentPage,
|
||||
'door_status' => $doorStatus,
|
||||
'firmware_version' => $firmwareVersion,
|
||||
'model' => $model,
|
||||
'last_heartbeat_at' => now(),
|
||||
];
|
||||
|
||||
@@ -35,9 +43,11 @@ class MachineService
|
||||
// Record log if provided
|
||||
if (!empty($data['log'])) {
|
||||
$machine->logs()->create([
|
||||
'company_id' => $machine->company_id,
|
||||
'type' => 'status',
|
||||
'level' => $data['log_level'] ?? 'info',
|
||||
'message' => $data['log'],
|
||||
'payload' => $data['log_payload'] ?? null,
|
||||
'context' => $data['log_payload'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -46,17 +56,43 @@ class MachineService
|
||||
}
|
||||
|
||||
/**
|
||||
* Update machine slot stock.
|
||||
* Sync machine slots based on replenishment report.
|
||||
*
|
||||
* @param Machine $machine
|
||||
* @param array $slotsData
|
||||
*/
|
||||
public function updateSlotStock(Machine $machine, int $slotNo, int $stock): void
|
||||
public function syncSlots(Machine $machine, array $slotsData): void
|
||||
{
|
||||
$machine->slots()->where('slot_no', $slotNo)->update([
|
||||
'stock' => $stock,
|
||||
'last_restocked_at' => now(),
|
||||
]);
|
||||
DB::transaction(function () use ($machine, $slotsData) {
|
||||
foreach ($slotsData as $slotData) {
|
||||
$slotNo = $slotData['slot_no'] ?? null;
|
||||
if (!$slotNo) continue;
|
||||
|
||||
$existingSlot = $machine->slots()->where('slot_no', $slotNo)->first();
|
||||
|
||||
$updateData = [
|
||||
'product_id' => $slotData['product_id'] ?? null,
|
||||
'stock' => $slotData['stock'] ?? 0,
|
||||
'capacity' => $slotData['capacity'] ?? ($existingSlot->capacity ?? 10),
|
||||
'price' => $slotData['price'] ?? ($existingSlot->price ?? 0),
|
||||
'last_restocked_at' => now(),
|
||||
];
|
||||
|
||||
// 如果商品變了,或者這是一次明確的補貨回報,清空效期等待管理員更新
|
||||
// 這裡我們暫定只要有 report 進來,就需要重新確認效期
|
||||
$updateData['expiry_date'] = null;
|
||||
|
||||
if ($existingSlot) {
|
||||
$existingSlot->update($updateData);
|
||||
} else {
|
||||
$machine->slots()->create(array_merge($updateData, ['slot_no' => $slotNo]));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update machine slot stock (single slot).
|
||||
* Legacy support for recordLog (Existing code).
|
||||
*/
|
||||
public function recordLog(int $machineId, array $data): MachineLog
|
||||
@@ -66,7 +102,129 @@ class MachineService
|
||||
return $machine->logs()->create([
|
||||
'level' => $data['level'] ?? 'info',
|
||||
'message' => $data['message'],
|
||||
'payload' => $data['context'] ?? null,
|
||||
'context' => $data['context'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get machine utilization and OEE statistics.
|
||||
*/
|
||||
public function getUtilizationStats(Machine $machine, string $date): array
|
||||
{
|
||||
$start = Carbon::parse($date)->startOfDay();
|
||||
$end = Carbon::parse($date)->endOfDay();
|
||||
|
||||
// 1. Availability: Based on heartbeat logs (status type)
|
||||
// Assume online if heartbeat within 6 minutes
|
||||
$logs = $machine->logs()
|
||||
->where('type', 'status')
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
$onlineMinutes = 0;
|
||||
$lastLogTime = null;
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$currentTime = Carbon::parse($log->created_at);
|
||||
if ($lastLogTime) {
|
||||
$diff = $currentTime->diffInMinutes($lastLogTime);
|
||||
if ($diff <= 6) {
|
||||
$onlineMinutes += $diff;
|
||||
}
|
||||
}
|
||||
$lastLogTime = $currentTime;
|
||||
}
|
||||
|
||||
$totalMinutes = 24 * 60;
|
||||
$availability = ($totalMinutes > 0) ? min(100, ($onlineMinutes / $totalMinutes) * 100) : 0;
|
||||
|
||||
// 2. Performance: Sales Count (B600)
|
||||
// Target: 2 sales per hour (48/day)
|
||||
$salesCount = $machine->logs()
|
||||
->where('message', 'like', '%B600%')
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->count();
|
||||
|
||||
$targetSales = 48;
|
||||
$performance = ($targetSales > 0) ? min(100, ($salesCount / $targetSales) * 100) : 0;
|
||||
|
||||
// 3. Quality: Success Rate
|
||||
// Exclude failed dispense (B130)
|
||||
$errorCount = $machine->logs()
|
||||
->where('message', 'like', '%B130%')
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->count();
|
||||
|
||||
$totalAttempts = $salesCount + $errorCount;
|
||||
$quality = ($totalAttempts > 0) ? (($salesCount / $totalAttempts) * 100) : 100;
|
||||
|
||||
// Combined OEE
|
||||
$oee = ($availability / 100) * ($performance / 100) * ($quality / 100) * 100;
|
||||
|
||||
return [
|
||||
'overview' => [
|
||||
'availability' => round($availability, 2),
|
||||
'performance' => round($performance, 2),
|
||||
'quality' => round($quality, 2),
|
||||
'oee' => round($oee, 2),
|
||||
'onlineHours' => round($onlineMinutes / 60, 2),
|
||||
'salesCount' => $salesCount,
|
||||
'errorCount' => $errorCount,
|
||||
],
|
||||
'chart' => [
|
||||
'uptime' => $this->formatUptimeTimeline($logs, $start, $end),
|
||||
'sales' => $this->formatSalesTimeline($machine, $start, $end)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
private function formatUptimeTimeline($logs, $start, $end)
|
||||
{
|
||||
$data = [];
|
||||
if ($logs->isEmpty()) return $data;
|
||||
|
||||
$lastLog = null;
|
||||
$currentRangeStart = null;
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$logTime = Carbon::parse($log->created_at);
|
||||
if (!$currentRangeStart) {
|
||||
$currentRangeStart = $logTime;
|
||||
} else {
|
||||
$diff = $logTime->diffInMinutes(Carbon::parse($lastLog->created_at));
|
||||
if ($diff > 10) { // Interruption > 10 mins
|
||||
$data[] = [
|
||||
'x' => 'Uptime',
|
||||
'y' => [$currentRangeStart->getTimestamp() * 1000, Carbon::parse($lastLog->created_at)->getTimestamp() * 1000],
|
||||
'fillColor' => '#06b6d4'
|
||||
];
|
||||
$currentRangeStart = $logTime;
|
||||
}
|
||||
}
|
||||
$lastLog = $log;
|
||||
}
|
||||
|
||||
if ($currentRangeStart && $lastLog) {
|
||||
$data[] = [
|
||||
'x' => 'Uptime',
|
||||
'y' => [$currentRangeStart->getTimestamp() * 1000, Carbon::parse($lastLog->created_at)->getTimestamp() * 1000],
|
||||
'fillColor' => '#06b6d4'
|
||||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function formatSalesTimeline($machine, $start, $end)
|
||||
{
|
||||
return $machine->logs()
|
||||
->where('message', 'like', '%B600%')
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->get()
|
||||
->map(function($log) {
|
||||
return [Carbon::parse($log->created_at)->getTimestamp() * 1000, 1];
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user