Files
star-cloud/app/Services/Machine/MachineService.php
sky121113 a599b14df1
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m6s
[FEAT] 優化機台硬體通訊協議與管理介面互動性
1. 修復帳號管理與角色權限頁面搜尋功能,支援 Enter 鍵快捷提交。
2. 完成 B013 (機台故障上報) API 實作,改用非同步隊列 (ProcessMachineError) 處理日誌上報。
3. 精簡 B013 API 參數,移除冗餘的 message 欄位,統一由雲端對照表翻譯。
4. 更新技術規格文件 (SKILL.md) 與系統 API 文件配置 (api-docs.php)。
5. 修正平台管理員帳號在搜尋過濾時的資料隔離邏輯。
2026-04-08 14:52:00 +08:00

443 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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
{
/**
* B013: 硬體狀態代碼對照表 (Hardware Status Code Mapping)
*/
public const ERROR_CODE_MAP = [
// 出貨狀態類 (Prefix: 04 - BUY_STATUS)
'0401' => ['label' => 'Dispensing in progress', 'level' => 'info'],
'0402' => ['label' => 'Dispense successful', 'level' => 'info'],
'0403' => ['label' => 'Slot jammed', 'level' => 'error'],
'0404' => ['label' => 'Motor not stopped', 'level' => 'warning'],
'0406' => ['label' => 'Slot not found', 'level' => 'error'],
'0407' => ['label' => 'Dispense error (0407)', 'level' => 'error'],
'0408' => ['label' => 'Dispense error (0408)', 'level' => 'error'],
'0409' => ['label' => 'Dispense error (0409)', 'level' => 'error'],
'040A' => ['label' => 'Dispense error (040A)', 'level' => 'error'],
'0410' => ['label' => 'Elevator rising', 'level' => 'info'],
'0411' => ['label' => 'Elevator descending', 'level' => 'info'],
'0412' => ['label' => 'Elevator rise error', 'level' => 'error'],
'0413' => ['label' => 'Elevator descent error', 'level' => 'error'],
'0414' => ['label' => 'Pickup door closed', 'level' => 'info'],
'0415' => ['label' => 'Pickup door error', 'level' => 'error'],
'0416' => ['label' => 'Delivery door opened', 'level' => 'info'],
'0417' => ['label' => 'Delivery door open error', 'level' => 'error'],
'0418' => ['label' => 'Delivering product', 'level' => 'info'],
'0419' => ['label' => 'Delivery door closed', 'level' => 'info'],
'0420' => ['label' => 'Delivery door close error', 'level' => 'error'],
'0421' => ['label' => 'Hopper empty', 'level' => 'warning'],
'0422' => ['label' => 'Hopper overheated', 'level' => 'warning'],
'0423' => ['label' => 'Hopper heating timeout', 'level' => 'error'],
'0424' => ['label' => 'Hopper error (0424)', 'level' => 'error'],
'0426' => ['label' => 'Microwave door opened', 'level' => 'info'],
'0427' => ['label' => 'Microwave door error', 'level' => 'error'],
'04FF' => ['label' => 'Dispense stopped', 'level' => 'info'],
// 貨道狀態類 (Prefix: 02 - SLOT_STATUS)
'0201' => ['label' => 'Slot normal', 'level' => 'info'],
'0202' => ['label' => 'Product empty', 'level' => 'warning'],
'0203' => ['label' => 'Slot empty', 'level' => 'warning'],
'0206' => ['label' => 'Slot not closed', 'level' => 'warning'],
'0207' => ['label' => 'Slot motor error (0207)', 'level' => 'error'],
'0208' => ['label' => 'Slot motor error (0208)', 'level' => 'error'],
'0209' => ['label' => 'Slot motor error (0209)', 'level' => 'error'],
'0212' => ['label' => 'Hopper empty (0212)', 'level' => 'warning'],
// 機台整體狀態類 (Prefix: 54 - MACHINE_STATUS)
'5400' => ['label' => 'Machine normal', 'level' => 'info'],
'5401' => ['label' => 'Elevator sensor error', 'level' => 'error'],
'5402' => ['label' => 'Pickup door not closed', 'level' => 'warning'],
'5403' => ['label' => 'Elevator failure', 'level' => 'error'],
];
/**
* 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) {
// 蒐集所有傳入的商品 ID (可能是 SKU 或 實際 ID)
$productCodes = collect($slotsData)->pluck('product_id')->filter()->unique()->toArray();
// 優先以 ID 查詢商品,若 ID 不存在則嘗試 Barcode (Prioritize ID lookup, fallback to Barcode)
$products = \App\Models\Product\Product::whereIn('id', $productCodes)
->orWhereIn('barcode', $productCodes)
->get();
foreach ($slotsData as $slotData) {
$slotNo = $slotData['slot_no'] ?? null;
if (!$slotNo) continue;
$existingSlot = $machine->slots()->where('slot_no', $slotNo)->first();
// 查找對應的實體 ID (支援 ID 與 Barcode 比對)
$productCode = $slotData['product_id'] ?? null;
$actualProductId = null;
if ($productCode) {
$actualProductId = $products->first(function ($p) use ($productCode) {
return (string)$p->id === (string)$productCode || $p->barcode === (string)$productCode;
})?->id;
}
// 根據貨道類型自動決定上限 (Auto-calculate max_stock based on slot type)
// 若未提供 type預設為 '1' (履帶/Track)
$slotType = $slotData['type'] ?? $existingSlot->type ?? '1';
if ($actualProductId) {
$product = $products->find($actualProductId);
if ($product) {
// 1: 履帶, 2: 彈簧
$calculatedMaxStock = ($slotType == '1') ? $product->track_limit : $product->spring_limit;
$slotData['capacity'] = $calculatedMaxStock ?? $slotData['capacity'] ?? null;
}
}
$updateData = [
'product_id' => $actualProductId,
'type' => $slotType,
'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,
]
]
]);
});
}
/**
* B013: Record machine hardware error/status log with auto-translation.
*
* @param Machine $machine
* @param array $data
* @return MachineLog
*/
public function recordErrorLog(Machine $machine, array $data): MachineLog
{
$errorCode = $data['error_code'] ?? '0000';
$mapping = self::ERROR_CODE_MAP[$errorCode] ?? ['label' => 'Unknown Status', 'level' => 'error'];
$slotNo = $data['tid'] ?? null;
$label = $mapping['label'];
// 儲存原始英文格式作為 DB 備用,前端顯示會優先使用 model accessor 的動態翻譯內容
$message = $slotNo ? "Slot {$slotNo}: {$label} (Code: {$errorCode})" : "{$label} (Code: {$errorCode})";
return $machine->logs()->create([
'company_id' => $machine->company_id,
'type' => 'submachine',
'level' => $mapping['level'],
'message' => $message,
'context' => array_merge($data, [
'translated_label' => $label,
'raw_code' => $errorCode
]),
]);
}
/**
* 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();
}
}