All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 58s
1. 實作 B012 API:新增 /api/v1/app/machine/products/B012 端點,支援 GET (全量) 與 PATCH (增量) 同步邏輯。 2. 統一圖片 URL:在 B005 與 B012 API 中使用 asset() 確保回傳絕對網址 (Absolute URL),解決 App 端下載相對路徑的問題。 3. 文件更新:同步更新 SKILL.md 的欄位定義,並在 api-docs.php 補上 B012 的正式規格說明。 4. 資料庫變更:新增 machine_slots 表的 type 欄位與相關註解遷移。 5. 格式優化:為技術規格文件中的 API 欄位與狀態碼加上反引號,提升文件中心可讀性。
363 lines
13 KiB
PHP
363 lines
13 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) {
|
||
// 蒐集所有傳入的商品 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,
|
||
]
|
||
]
|
||
]);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
}
|
||
}
|