star-cloud/app/Services/Machine/MachineService.php
sky121113 8feca33f13 [FEAT] 優化機台日誌管理、新增庫存變動紀錄與取貨碼/通行碼介面改善
1. 新增機台異常日誌手動解除功能,包含資料庫欄位更新與 UI 操作按鈕。
2. 實作機台庫存變動紀錄系統 (Machine Stock Movements),支援追蹤補貨與銷售產生的庫存異動。
3. 重構取貨碼 (Pickup Code) 與通行碼 (Pass Code) 管理介面,採用極簡奢華風 UI 並拆分 Partial Views 提升可維護性。
4. 優化取貨操作日誌,明確區分「取貨成功」與「取貨失敗」,並加入顏色標籤增強辨識度。
5. 擴充多語系支援 (繁中、英文、日文),確保所有新功能與操作狀態均有對應翻譯。
6. 更新 IoT API 規格文件與相關 Service 邏輯,加強指令確認 (ACK) 處理機制。
2026-05-04 08:43:35 +08:00

624 lines
24 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\MachineSlot;
use App\Models\Machine\MachineStockMovement;
use App\Models\Machine\RemoteCommand;
use Illuminate\Support\Facades\Auth;
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 API token to Redis for MQTT authentication.
*
* @param Machine $machine
*/
public function syncMqttAuth(Machine $machine): void
{
if (empty($machine->api_token)) {
return;
}
// MQTT 連線認證Username = serial_no, Password = hash(api_token)
// 遵循 framework.md 4.5 規範
$redisKey = "machine_auth:{$machine->serial_no}";
// 這裡採用 SHA256 雜湊,與 EMQX 設定對應
$hashedToken = hash('sha256', $machine->api_token);
\Illuminate\Support\Facades\Redis::hSet($redisKey, 'password', $hashedToken);
\Illuminate\Support\Facades\Log::info("MQTT Auth synced to Redis", [
'serial_no' => $machine->serial_no,
'key' => $redisKey
]);
}
/**
* 統一寫入機台庫存異動紀錄
*
* @param MachineSlot $slot 貨道 Model已含 machine_id, product_id, stock
* @param int $delta 異動數量(正=增加,負=減少)
* @param string $type 異動類型MachineStockMovement::TYPE_*
* @param mixed $reference 關聯單據 Modelnullable
* @param string|null $note 備註說明
*/
public function recordStockMovement(
MachineSlot $slot,
int $delta,
string $type,
mixed $reference = null,
?string $note = null,
array $context = []
): MachineStockMovement {
$before = $slot->stock - $delta; // 因為已更新 slot反推異動前的值
return MachineStockMovement::create([
'company_id' => $slot->machine->company_id ?? null,
'machine_id' => $slot->machine_id,
'product_id' => $slot->product_id,
'slot_no' => $slot->slot_no,
'type' => $type,
'quantity' => $delta,
'before_qty' => $before,
'after_qty' => $slot->stock,
'reference_type' => $reference ? get_class($reference) : null,
'reference_id' => $reference?->id,
'note' => $note,
'context' => $context,
'created_by' => Auth::id(),
]);
}
/**
* 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;
}
}
$newStock = $slotData['stock'] ?? 0;
$updateData = [
'product_id' => $actualProductId,
'type' => $slotType,
'stock' => $newStock,
'max_stock' => $slotData['capacity'] ?? ($existingSlot->max_stock ?? 10),
'is_active' => true,
];
if ($existingSlot) {
// B009 回報:若數值有變化,記錄 adjustment
$oldStock = (int) $existingSlot->stock;
$existingSlot->update($updateData);
if ($oldStock !== $newStock) {
$delta = $newStock - $oldStock;
$existingSlot->refresh();
$this->recordStockMovement(
$existingSlot,
$delta,
MachineStockMovement::TYPE_ADJUSTMENT,
null,
"movement.note.replenishment_correction",
['old' => $oldStock, 'new' => $newStock]
);
}
} 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)->lockForUpdate()->firstOrFail();
// 1. 併行檢查:確保同一個貨道在同一時間只有一個指令在執行中 (Pending)
// 包含 reload_stock 與 dispense 兩種會異動庫存的類型
$isPending = RemoteCommand::where('machine_id', $machine->id)
->whereIn('command_type', ['reload_stock', 'dispense'])
->where('status', 'pending')
->whereJsonContains('payload->slot_no', (string)$slotNo)
->exists();
if ($isPending) {
throw new \Exception(__('This slot has a pending update. Please wait for the previous command to complete.'));
}
// 紀錄舊數據以供回滾使用
$oldData = [
'stock' => $slot->stock,
'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null,
'batch_no' => $slot->batch_no,
];
// 2. 執行樂觀更新 (Optimistic Update)
$updateData = [
'expiry_date' => $expiryDate,
'batch_no' => $batchNo,
];
if ($stock !== null) $updateData['stock'] = (int)$stock;
$slot->update($updateData);
// 3. 若庫存數值有異動,記錄 adjustment 流水帳
if ($stock !== null && (int)$stock !== $oldData['stock']) {
$delta = (int)$stock - $oldData['stock'];
$slot->refresh();
$this->recordStockMovement(
$slot,
$delta,
MachineStockMovement::TYPE_ADJUSTMENT,
null,
"movement.note.manual_adjustment",
['old' => $oldData['stock'], 'new' => $stock]
);
}
// 3. 建立遠端指令紀錄
$command = RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => $userId,
'command_type' => 'reload_stock',
'status' => 'pending',
'payload' => [
'slot_no' => (string)$slotNo,
'old' => $oldData,
'new' => [
'stock' => $stock !== null ? (int)$stock : $oldData['stock'],
'expiry_date' => $expiryDate ?: null,
'batch_no' => $batchNo ?: null,
]
]
]);
// 4. 推播 MQTT
$mqttPayload = [
'data' => [
[
'slot_no' => $command->payload['slot_no'],
'stock' => $command->payload['new']['stock'],
'expiry_date' => $command->payload['new']['expiry_date'],
'batch_no' => $command->payload['new']['batch_no'] ?? null,
]
]
];
app(\App\Services\Machine\MqttService::class)->pushCommand(
$machine->serial_no,
'update_inventory',
$mqttPayload,
(string) $command->id
);
});
}
/**
* 遠端出貨指令下發 (含樂觀扣庫存與鎖定)
*/
public function dispatchDispense(Machine $machine, string $slotNo, ?int $userId = null): RemoteCommand
{
return DB::transaction(function () use ($machine, $slotNo, $userId) {
$slot = $machine->slots()->where('slot_no', $slotNo)->lockForUpdate()->firstOrFail();
// 併行檢查
$isPending = RemoteCommand::where('machine_id', $machine->id)
->whereIn('command_type', ['reload_stock', 'dispense'])
->where('status', 'pending')
->whereJsonContains('payload->slot_no', (string)$slotNo)
->exists();
if ($isPending) {
throw new \Exception(__('This slot has a pending command. Please wait.'));
}
if ($slot->stock <= 0) {
throw new \Exception(__('Out of stock.'));
}
$oldStock = $slot->stock;
$newStock = $oldStock - 1;
// 1. 執行樂觀扣除
$slot->update(['stock' => $newStock]);
// 2. 建立指令紀錄
$command = RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => $userId,
'command_type' => 'dispense',
'status' => 'pending',
'payload' => [
'slot_no' => (string)$slotNo,
'old_stock' => $oldStock,
'new_stock' => $newStock,
]
]);
// 3. 寫入庫存異動流水帳remote_dispense樂觀扣除
$slot->refresh();
$this->recordStockMovement(
$slot,
-1,
MachineStockMovement::TYPE_REMOTE_DISPENSE,
$command,
"遠端出貨指令 (B055),指令 ID: {$command->id}"
);
// 4. 推播 MQTT
app(\App\Services\Machine\MqttService::class)->pushCommand(
$machine->serial_no,
'dispense',
$command->payload,
(string) $command->id
);
return $command;
});
}
/**
* 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();
}
}