[FEAT] 實作維修管理模組與 RBAC 權限整合、多語系支援及 UI 優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m3s
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m3s
This commit is contained in:
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin\BasicSettings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -11,6 +12,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MachinePhotoController extends Controller
|
||||
{
|
||||
use ImageHandler;
|
||||
|
||||
/**
|
||||
* 更新機台照片
|
||||
*/
|
||||
@@ -64,50 +67,4 @@ class MachinePhotoController extends Controller
|
||||
return back()->with('error', __('Failed to update machine images: ') . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 將圖片轉換為 WebP 並儲存
|
||||
*/
|
||||
protected function storeAsWebp($file, $directory): string
|
||||
{
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$filename = uniqid() . '.webp';
|
||||
$path = "{$directory}/{$filename}";
|
||||
|
||||
// 讀取原始圖片
|
||||
$imageType = exif_imagetype($file->getRealPath());
|
||||
switch ($imageType) {
|
||||
case IMAGETYPE_JPEG:
|
||||
$source = imagecreatefromjpeg($file->getRealPath());
|
||||
break;
|
||||
case IMAGETYPE_PNG:
|
||||
$source = imagecreatefrompng($file->getRealPath());
|
||||
break;
|
||||
case IMAGETYPE_WEBP:
|
||||
$source = imagecreatefromwebp($file->getRealPath());
|
||||
break;
|
||||
default:
|
||||
// 如果格式不支援,直接存
|
||||
return $file->storeAs($directory, $file->hashName(), 'public');
|
||||
}
|
||||
|
||||
if (!$source) {
|
||||
return $file->storeAs($directory, $file->hashName(), 'public');
|
||||
}
|
||||
|
||||
// 確保支援真彩色(解決 palette image 問題)
|
||||
if (!imageistruecolor($source)) {
|
||||
imagepalettetotruecolor($source);
|
||||
}
|
||||
|
||||
// 捕捉輸出
|
||||
ob_start();
|
||||
imagewebp($source, null, 80);
|
||||
$content = ob_get_clean();
|
||||
imagedestroy($source);
|
||||
|
||||
Storage::disk('public')->put($path, $content);
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Admin\AdminController;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineModel;
|
||||
use App\Models\System\PaymentConfig;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -16,6 +17,8 @@ use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MachineSettingController extends AdminController
|
||||
{
|
||||
use ImageHandler;
|
||||
|
||||
/**
|
||||
* 顯示機台與型號設定列表 (採用標籤頁整合)
|
||||
*/
|
||||
@@ -75,7 +78,7 @@ class MachineSettingController extends AdminController
|
||||
$imagePaths = [];
|
||||
if ($request->hasFile('images')) {
|
||||
foreach (array_slice($request->file('images'), 0, 3) as $image) {
|
||||
$imagePaths[] = $this->processAndStoreImage($image);
|
||||
$imagePaths[] = $this->storeAsWebp($image, 'machines');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +174,7 @@ class MachineSettingController extends AdminController
|
||||
}
|
||||
|
||||
// 處理並儲存新圖
|
||||
$currentImages[$index] = $this->processAndStoreImage($file);
|
||||
$currentImages[$index] = $this->storeAsWebp($file, 'machines');
|
||||
$updated = true;
|
||||
}
|
||||
|
||||
@@ -184,49 +187,4 @@ class MachineSettingController extends AdminController
|
||||
return redirect()->route('admin.basic-settings.machines.index')
|
||||
->with('success', __('Machine settings updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 處理並儲存圖片 (轉換為 WebP 並調整大小)
|
||||
*/
|
||||
protected function processAndStoreImage($file)
|
||||
{
|
||||
$path = 'machines/' . \Illuminate\Support\Str::random(40) . '.webp';
|
||||
|
||||
// 載入原圖
|
||||
$imageInfo = getimagesize($file->getRealPath());
|
||||
$mime = $imageInfo['mime'];
|
||||
|
||||
switch ($mime) {
|
||||
case 'image/jpeg':
|
||||
$image = imagecreatefromjpeg($file->getRealPath());
|
||||
break;
|
||||
case 'image/png':
|
||||
$image = imagecreatefrompng($file->getRealPath());
|
||||
break;
|
||||
case 'image/gif':
|
||||
$image = imagecreatefromgif($file->getRealPath());
|
||||
break;
|
||||
default:
|
||||
return $file->store('machines', 'public');
|
||||
}
|
||||
|
||||
if ($image) {
|
||||
// [修正] imagewebp(): Palette image not supported by webp
|
||||
// 若為 Palette 圖片 (例如 GIF),轉換為 Truecolor
|
||||
if (!imageistruecolor($image)) {
|
||||
imagepalettetotruecolor($image);
|
||||
}
|
||||
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->makeDirectory('machines');
|
||||
$fullPath = \Illuminate\Support\Facades\Storage::disk('public')->path($path);
|
||||
|
||||
// 轉換並儲存 (品質 80)
|
||||
imagewebp($image, $fullPath, 80);
|
||||
imagedestroy($image);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
return $file->store('machines', 'public');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,9 +172,20 @@ class MachineController extends AdminController
|
||||
{
|
||||
// 取得當前使用者有權限的所有機台 (已透過 Global Scope 過濾)
|
||||
$machines = Machine::all();
|
||||
|
||||
$date = $request->get('date', now()->toDateString());
|
||||
$service = app(\App\Services\Machine\MachineService::class);
|
||||
$fleetStats = $service->getFleetStats($date);
|
||||
|
||||
return view('admin.machines.utilization', [
|
||||
'machines' => $machines
|
||||
'machines' => $machines,
|
||||
'fleetStats' => $fleetStats,
|
||||
'compactMachines' => $machines->map(fn($m) => [
|
||||
'id' => $m->id,
|
||||
'name' => $m->name,
|
||||
'serial_no' => $m->serial_no,
|
||||
'status' => $m->status
|
||||
])->values()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -225,13 +236,17 @@ class MachineController extends AdminController
|
||||
/**
|
||||
* 取得機台統計數據 (AJAX)
|
||||
*/
|
||||
public function utilizationData(int $id, Request $request)
|
||||
public function utilizationData(Request $request, $id = null)
|
||||
{
|
||||
$machine = Machine::findOrFail($id);
|
||||
$date = $request->get('date', now()->toDateString());
|
||||
|
||||
$service = app(\App\Services\Machine\MachineService::class);
|
||||
$stats = $service->getUtilizationStats($machine, $date);
|
||||
|
||||
if ($id) {
|
||||
$machine = Machine::findOrFail($id);
|
||||
$stats = $service->getUtilizationStats($machine, $date);
|
||||
} else {
|
||||
$stats = $service->getFleetStats($date);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
||||
105
app/Http/Controllers/Admin/MaintenanceController.php
Normal file
105
app/Http/Controllers/Admin/MaintenanceController.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MaintenanceRecord;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MaintenanceController extends Controller
|
||||
{
|
||||
use ImageHandler;
|
||||
|
||||
/**
|
||||
* 維修紀錄列表
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', MaintenanceRecord::class);
|
||||
|
||||
$query = MaintenanceRecord::with(['machine', 'user', 'company'])
|
||||
->latest('maintenance_at');
|
||||
|
||||
// 搜尋邏輯
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->whereHas('machine', function($q) use ($search) {
|
||||
$q->where('serial_no', 'like', "%{$search}%")
|
||||
->orWhere('name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('category')) {
|
||||
$query->where('category', $request->category);
|
||||
}
|
||||
|
||||
$records = $query->paginate(15)->withQueryString();
|
||||
|
||||
return view('admin.maintenance.index', compact('records'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示新增維修單頁面
|
||||
*/
|
||||
public function create(Request $request, $serial_no = null)
|
||||
{
|
||||
$this->authorize('create', MaintenanceRecord::class);
|
||||
|
||||
$machine = null;
|
||||
if ($serial_no) {
|
||||
$machine = Machine::where('serial_no', $serial_no)->firstOrFail();
|
||||
}
|
||||
|
||||
// 供手動新增時選擇的機台清單 (僅限有權限存取的)
|
||||
$machines = Machine::all();
|
||||
|
||||
return view('admin.maintenance.create', compact('machine', 'machines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存維修單
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$this->authorize('create', MaintenanceRecord::class);
|
||||
|
||||
$validated = $request->validate([
|
||||
'machine_id' => 'required|exists:machines,id',
|
||||
'category' => 'required|in:Repair,Installation,Removal,Maintenance',
|
||||
'content' => 'nullable|string',
|
||||
'maintenance_at' => 'required|date',
|
||||
'photos.*' => 'nullable|image|max:5120', // 每張上限 5MB
|
||||
]);
|
||||
|
||||
$machine = Machine::findOrFail($validated['machine_id']);
|
||||
|
||||
$photoPaths = [];
|
||||
if ($request->hasFile('photos')) {
|
||||
foreach ($request->file('photos') as $photo) {
|
||||
if (!$photo) continue;
|
||||
if (count($photoPaths) >= 3) break;
|
||||
|
||||
// 轉為 WebP 格式與保存
|
||||
$path = $this->storeAsWebp($photo, "maintenance/{$machine->id}");
|
||||
$photoPaths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
$record = MaintenanceRecord::create([
|
||||
'company_id' => $machine->company_id, // 從機台帶入歸屬客戶
|
||||
'machine_id' => $machine->id,
|
||||
'user_id' => Auth::id(),
|
||||
'category' => $validated['category'],
|
||||
'content' => $validated['content'],
|
||||
'photos' => $photoPaths,
|
||||
'maintenance_at' => $validated['maintenance_at'],
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.maintenance.index')
|
||||
->with('success', __('Maintenance record created successfully'));
|
||||
}
|
||||
}
|
||||
45
app/Models/Machine/MaintenanceRecord.php
Normal file
45
app/Models/Machine/MaintenanceRecord.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
use App\Traits\TenantScoped;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use App\Models\System\User;
|
||||
use App\Models\Machine\Machine;
|
||||
|
||||
class MaintenanceRecord extends Model
|
||||
{
|
||||
use TenantScoped, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
'user_id',
|
||||
'category',
|
||||
'content',
|
||||
'photos',
|
||||
'maintenance_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'photos' => 'array',
|
||||
'maintenance_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\Company::class);
|
||||
}
|
||||
}
|
||||
66
app/Policies/Machine/MaintenanceRecordPolicy.php
Normal file
66
app/Policies/Machine/MaintenanceRecordPolicy.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies\Machine;
|
||||
|
||||
use App\Models\Machine\MaintenanceRecord;
|
||||
use App\Models\System\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class MaintenanceRecordPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return $user->isSystemAdmin() && $user->can('menu.machines.maintenance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, MaintenanceRecord $maintenanceRecord): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
* @var array<class-string, class-string>
|
||||
*/
|
||||
protected $policies = [
|
||||
//
|
||||
\App\Models\Machine\MaintenanceRecord::class => \App\Policies\Machine\MaintenanceRecordPolicy::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,6 +106,51 @@ class MachineService
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get machine utilization and OEE statistics for entire fleet.
|
||||
*/
|
||||
public function getFleetStats(string $date): array
|
||||
{
|
||||
$start = Carbon::parse($date)->startOfDay();
|
||||
$end = Carbon::parse($date)->endOfDay();
|
||||
|
||||
// 1. Online Count (Base on current status)
|
||||
$machines = Machine::all(); // This is filtered by TenantScoped
|
||||
$totalMachines = $machines->count();
|
||||
$onlineCount = $machines->where('status', 'online')->count();
|
||||
|
||||
$machineIds = $machines->pluck('id')->toArray();
|
||||
|
||||
// 2. Total Daily Sales (Sum of B600 logs across all authorized machines)
|
||||
$totalSales = MachineLog::whereIn('machine_id', $machineIds)
|
||||
->where('message', 'like', '%B600%')
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->count();
|
||||
|
||||
// 3. Average OEE (Simulated based on individual machine stats for performance)
|
||||
$totalOee = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($machines as $machine) {
|
||||
$stats = $this->getUtilizationStats($machine, $date);
|
||||
$totalOee += $stats['overview']['oee'];
|
||||
$count++;
|
||||
}
|
||||
|
||||
$avgOee = ($count > 0) ? ($totalOee / $count) : 0;
|
||||
|
||||
return [
|
||||
'avgOee' => round($avgOee, 2),
|
||||
'onlineCount' => $onlineCount,
|
||||
'totalMachines' => $totalMachines,
|
||||
'totalSales' => $totalSales,
|
||||
'alertCount' => MachineLog::whereIn('machine_id', $machineIds)
|
||||
->where('level', 'error')
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->count()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get machine utilization and OEE statistics.
|
||||
*/
|
||||
|
||||
70
app/Traits/ImageHandler.php
Normal file
70
app/Traits/ImageHandler.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait ImageHandler
|
||||
{
|
||||
/**
|
||||
* 將圖片轉換為 WebP 並儲存
|
||||
*
|
||||
* @param UploadedFile $file 原始檔案
|
||||
* @param string $directory 儲存目錄 (不含 disk 名稱)
|
||||
* @param int $quality 壓縮品質 (0-100)
|
||||
* @return string 儲存的路徑
|
||||
*/
|
||||
protected function storeAsWebp(UploadedFile $file, string $directory, int $quality = 80): string
|
||||
{
|
||||
$filename = Str::random(40) . '.webp';
|
||||
$path = "{$directory}/{$filename}";
|
||||
|
||||
// 讀取原始圖片資訊
|
||||
$imageInfo = getimagesize($file->getRealPath());
|
||||
if (!$imageInfo) {
|
||||
return $file->store($directory, 'public');
|
||||
}
|
||||
|
||||
$mime = $imageInfo['mime'];
|
||||
$source = null;
|
||||
|
||||
switch ($mime) {
|
||||
case 'image/jpeg':
|
||||
$source = imagecreatefromjpeg($file->getRealPath());
|
||||
break;
|
||||
case 'image/png':
|
||||
$source = imagecreatefrompng($file->getRealPath());
|
||||
break;
|
||||
case 'image/gif':
|
||||
$source = imagecreatefromgif($file->getRealPath());
|
||||
break;
|
||||
case 'image/webp':
|
||||
$source = imagecreatefromwebp($file->getRealPath());
|
||||
break;
|
||||
default:
|
||||
// 不支援的格式直接存
|
||||
return $file->store($directory, 'public');
|
||||
}
|
||||
|
||||
if (!$source) {
|
||||
return $file->store($directory, 'public');
|
||||
}
|
||||
|
||||
// 確保支援真彩色 (解決 palette image 問題)
|
||||
if (!imageistruecolor($source)) {
|
||||
imagepalettetotruecolor($source);
|
||||
}
|
||||
|
||||
// 確保目錄存在
|
||||
Storage::disk('public')->makeDirectory($directory);
|
||||
$fullPath = Storage::disk('public')->path($path);
|
||||
|
||||
// 轉換並儲存
|
||||
imagewebp($source, $fullPath, $quality);
|
||||
imagedestroy($source);
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user