All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m7s
1. 修改 MachineController@getAdvertisements 回傳欄位,將 t070v02 改為播放秒數,t070v03 改為位置代碼 (Flag)。 2. 根據 Android App 原始碼分析結果,調整位置對應:3 為待機廣告 (HomeActivity),1 為販賣頁廣告 (FontendActivity)。 3. 在 AdvertisementController@getMachineAds 增加 sort_order 排序,確保後台管理介面視圖與 API 輸出順序一致。 4. 更新廣告下載 API 的技術文件 (SKILL.md),明確標記欄位用途與位置代碼。 5. 在 routes/api.php 補上 B005 與 B009 的路由定義。
349 lines
12 KiB
PHP
349 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) {
|
|
// 蒐集所有傳入的商品 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();
|
|
}
|
|
}
|