All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m18s
1. 重構機台在線狀態判定機制:移除資料庫 status 欄位,改由 Model 根據心跳時間動態計算。 2. 修正儀表板 (Dashboard) 與機台管理頁面的多語系顯示問題,解決換行導致翻譯失效的 Bug。 3. 修正個人檔案頁面的麵包屑 (Breadcrumbs) 導航,補齊「個人設定」層級。 4. 更新 IoT API (B010, B600) 的認證機制與日誌處理邏輯。 5. 同步更新繁中、英文、日文語言檔,確保 UI 標籤一致性。
337 lines
12 KiB
PHP
337 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Machine;
|
|
|
|
use App\Models\Machine\Machine;
|
|
use App\Models\Machine\MachineLog;
|
|
use App\Models\Machine\RemoteCommand;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Carbon\Carbon;
|
|
|
|
class MachineService
|
|
{
|
|
/**
|
|
* Update machine heartbeat and status.
|
|
*
|
|
* @param string $serialNo
|
|
* @param array $data
|
|
* @return Machine
|
|
*/
|
|
public function updateHeartbeat(string $serialNo, array $data): Machine
|
|
{
|
|
return DB::transaction(function () use ($serialNo, $data) {
|
|
$machine = Machine::where('serial_no', $serialNo)->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) {
|
|
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
|
|
* @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();
|
|
}
|
|
}
|