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 = [ '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) { // 蒐集所有傳入的商品 ID (可能是 SKU 或 實際 ID) $productCodes = collect($slotsData)->pluck('product_id')->filter()->unique()->toArray(); // 批次查詢商品 (支援以 SKU 查詢,確保對應至資料庫 ID) $products = \App\Models\Product\Product::whereIn('sku', $productCodes) ->orWhereIn('id', $productCodes) ->get() ->keyBy(fn($p) => $p->sku ?: $p->id); foreach ($slotsData as $slotData) { $slotNo = $slotData['slot_no'] ?? null; if (!$slotNo) continue; $existingSlot = $machine->slots()->where('slot_no', $slotNo)->first(); // 查找對應的實體 ID $productCode = $slotData['product_id'] ?? null; $actualProductId = null; if ($productCode) { $actualProductId = $products->get($productCode)?->id; } $updateData = [ 'product_id' => $actualProductId, 'stock' => $slotData['stock'] ?? 0, 'max_stock' => $slotData['capacity'] ?? ($existingSlot->max_stock ?? 10), 'is_active' => true, ]; // 如果這是一次明確的補貨回報,建議更新時間並記錄 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 * @param int|null $userId * @return void */ public function updateSlot(Machine $machine, array $data, ?int $userId = null): void { DB::transaction(function () use ($machine, $data, $userId) { $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(); // 紀錄舊數據以供 Payload 使用 $oldData = [ 'stock' => $slot->stock, 'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null, 'batch_no' => $slot->batch_no, ]; $updateData = [ 'expiry_date' => $expiryDate, 'batch_no' => $batchNo, ]; if ($stock !== null) $updateData['stock'] = (int)$stock; $slot->update($updateData); // 指令去重:將該機台所有尚未領取的舊庫存同步指令標記為「已取代」 RemoteCommand::where('machine_id', $machine->id) ->where('command_type', 'reload_stock') ->where('status', 'pending') ->update([ 'status' => 'superseded', 'note' => __('Superseded by new adjustment'), 'executed_at' => now(), ]); // 建立遠端指令紀錄 (Unified Command Concept) RemoteCommand::create([ 'machine_id' => $machine->id, 'user_id' => $userId, 'command_type' => 'reload_stock', 'status' => 'pending', 'payload' => [ 'slot_no' => $slotNo, 'old' => $oldData, 'new' => [ 'stock' => $stock !== null ? (int)$stock : $oldData['stock'], 'expiry_date' => $expiryDate ?: null, 'batch_no' => $batchNo ?: null, ] ] ]); }); } /** * 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 new heartbeat logic) $machines = Machine::all(); // This is filtered by TenantScoped $totalMachines = $machines->count(); $onlineCount = Machine::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(); } }