firstOrFail(); // 採用現代化語意命名 (Modern semantic naming) $temperature = $data['temperature'] ?? $machine->temperature; $currentPage = $data['current_page'] ?? $machine->current_page; $doorStatus = $data['door_status'] ?? $machine->door_status; $firmwareVersion = $data['firmware_version'] ?? $machine->firmware_version; $model = $data['model'] ?? $machine->model; $updateData = [ 'status' => 'online', 'temperature' => $temperature, 'current_page' => $currentPage, 'door_status' => $doorStatus, 'firmware_version' => $firmwareVersion, 'model' => $model, 'last_heartbeat_at' => now(), ]; $machine->update($updateData); // 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'], 'context' => $data['log_payload'] ?? null, ]); } return $machine; }); } /** * Sync machine slots based on replenishment report. * * @param Machine $machine * @param array $slotsData */ public function syncSlots(Machine $machine, array $slotsData): void { 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, expiry, and batch. * * @param Machine $machine * @param array $data * @return void */ public function updateSlot(Machine $machine, array $data): void { DB::transaction(function () use ($machine, $data) { $slotNo = $data['slot_no']; $stock = $data['stock'] ?? null; $expiryDate = $data['expiry_date'] ?? null; $batchNo = $data['batch_no'] ?? null; $slot = $machine->slots()->where('slot_no', $slotNo)->firstOrFail(); $updateData = [ 'expiry_date' => $expiryDate, 'batch_no' => $batchNo, ]; if ($stock !== null) $updateData['stock'] = (int)$stock; $slot->update($updateData); }); } /** * Update machine slot stock (single slot). * Legacy support for recordLog (Existing code). */ public function recordLog(int $machineId, array $data): MachineLog { $machine = Machine::findOrFail($machineId); return $machine->logs()->create([ 'level' => $data['level'] ?? 'info', 'message' => $data['message'], 'context' => $data['context'] ?? null, ]); } /** * 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. */ 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(); } }