1. 刷卡機共用判定:信用卡(1)、電子票證(2)、手機支付(10) 走同一台實體刷卡機,以交易結果維護燈號(刷卡機無斷線心跳)。 2. ProcessTransactionFinalized 新增 syncCardTerminalStatus:成功交易自動消除該機台未解決的 card_terminal 警告;失敗交易解析 payment_response 的 "| code=XXXX" 回應碼,寫一筆 type='card_terminal' 的 warning 日誌(warning 級不觸發 Discord)。 3. MachineService 新增 CARD_TERMINAL_CODE_MAP(NCCC/聯卡中心回應碼對照,值為英文翻譯鍵);MachineLog 依 card_code 重組多語可讀失敗原因。 4. Machine 模型新增 card_terminal_status 與 latest_card_terminal_log_time 取值器;MachineController 列表以 withCount + 相關子查詢預載彙總,避免 N+1。 5. 機台列表頁新增「刷卡機」狀態燈號(綠/琥珀/紅 + 最近異常時間)與「刷卡機日誌」分頁。 6. 文件更新:config/api-docs.php 與 api-technical-specs SKILL.md 補充 payment_type 說明(2/10 與 1 共用同一台刷卡機、補列 42 取物單與 100 遠端出貨)。 7. 既有 migration change_products_spec_to_text 加 sqlite 守衛,讓測試環境可跑(VARCHAR/TEXT 在 sqlite 等價且不支援 MODIFY)。 8. 新增 CardTerminalStatusTest 功能測試(18 斷言通過,涵蓋成功消警、失敗寫日誌、掃碼排除、多語訊息、N+1 預載路徑)。 9. 新增 25 組三語系 key(刷卡機狀態與回應碼),zh_TW/en/ja 對齊並 ksort 排序。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
773 lines
36 KiB
PHP
773 lines
36 KiB
PHP
<?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 App\Models\Product\Product;
|
||
use App\Models\Transaction\Order;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Carbon\Carbon;
|
||
|
||
class MachineService
|
||
{
|
||
private const SPRING_SLOT_FLAGS = [
|
||
[1, 10, 'is_spring_slot_1_10'],
|
||
[11, 20, 'is_spring_slot_11_20'],
|
||
[21, 30, 'is_spring_slot_21_30'],
|
||
[31, 40, 'is_spring_slot_31_40'],
|
||
[41, 50, 'is_spring_slot_41_50'],
|
||
[51, 60, 'is_spring_slot_51_60'],
|
||
];
|
||
|
||
/**
|
||
* B013: 硬體狀態代碼對照表 (Hardware Status Code Mapping)
|
||
*
|
||
* 代碼格式:Command 0x04 + Status Byte,例如 0x04+0x03 = '0403'
|
||
* 出貨結果判定:僅 0402 (出貨成功) 與 0424 (商品取出成功) 視為成功,
|
||
* 其餘出貨結果碼均應觸發退款。
|
||
*
|
||
* 參考文件:廠商硬體通訊協議 Command(0x04) 狀態定義
|
||
*/
|
||
public const ERROR_CODE_MAP = [
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 出貨與物理動作類 (Command 0x04 - 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'], // 電機未正常停止
|
||
'0405' => ['label' => 'Motor not found', 'level' => 'error'], // 電機不存在
|
||
'0406' => ['label' => 'Motor not found (0406)', 'level' => 'error'], // 電機不存在 (同 0405)
|
||
'0407' => ['label' => 'Elevator failure', 'level' => 'error'], // 昇降機故障
|
||
|
||
// --- 昇降機系統 ---
|
||
'040A' => ['label' => 'Elevator belt self-check error', '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'], // 昇降機下降錯誤
|
||
'0425' => ['label' => 'Elevator support rod return error', 'level' => 'error'], // 昇降台撐桿回位錯誤
|
||
'0428' => ['label' => 'Elevator support rod push error', 'level' => 'error'], // 昇降台撐桿推出錯誤
|
||
'0429' => ['label' => 'Elevator enter microwave error', 'level' => 'error'], // 昇降台進微波爐錯誤
|
||
'0430' => ['label' => 'Elevator exit microwave error', 'level' => 'error'], // 昇降台出微波爐錯誤
|
||
|
||
// --- 微波爐取貨口門 ---
|
||
'0414' => ['label' => 'Microwave pickup door closing', 'level' => 'info'], // 正在關閉微波爐取貨口門
|
||
'0415' => ['label' => 'Microwave pickup door close error', 'level' => 'error'], // 微波爐取貨口門關閉錯誤
|
||
'0426' => ['label' => 'Microwave pickup door opening', 'level' => 'info'], // 正在打開微波爐取貨口門
|
||
'0427' => ['label' => 'Microwave pickup door open error', 'level' => 'error'], // 微波爐取貨口門打開錯誤
|
||
|
||
// --- 微波爐進貨口門 ---
|
||
'0416' => ['label' => 'Microwave delivery door opening', 'level' => 'info'], // 正在打開微波爐進貨口門
|
||
'0417' => ['label' => 'Microwave delivery door open error', 'level' => 'error'], // 微波爐進貨口門打開錯誤
|
||
'0418' => ['label' => 'Pushing product into microwave', 'level' => 'info'], // 正在將商品推入微波爐
|
||
'0419' => ['label' => 'Microwave delivery door closing', 'level' => 'info'], // 正在關閉微波爐進貨口門
|
||
'0420' => ['label' => 'Microwave delivery door close error', 'level' => 'error'], // 微波爐進貨口門關閉錯誤
|
||
|
||
// --- 微波爐內部 ---
|
||
'0421' => ['label' => 'Product not detected in microwave', 'level' => 'error'], // 微波爐內未檢測到商品
|
||
'0422' => ['label' => 'Product heating', 'level' => 'info'], // 商品正在加熱
|
||
'0423' => ['label' => 'Product heating remaining time', 'level' => 'info'], // 商品加熱剩餘時間 (貨道號=秒數)
|
||
'0424' => ['label' => 'Purchase successful', 'level' => 'info'], // ✅ 購買成功
|
||
'0431' => ['label' => 'Microwave push rod push error', 'level' => 'error'], // 微波爐內推桿推出錯誤
|
||
'0432' => ['label' => 'Microwave push rod retract error', 'level' => 'error'], // 微波爐內推桿收回錯誤
|
||
|
||
// --- 鮮奶機專用 ---
|
||
'040B' => ['label' => 'Milk dispense timeout', 'level' => 'error'], // 按鍵出奶超時 (鮮奶機)
|
||
'040C' => ['label' => 'Cup dropper failure', 'level' => 'error'], // 漏杯器漏杯失敗 (鮮奶機)
|
||
'040D' => ['label' => 'Cup drop not detected', 'level' => 'error'], // 未檢測到杯子掉落 (鮮奶機)
|
||
|
||
// --- 手環機專用 ---
|
||
'040E' => ['label' => 'Bracelet scan failed', 'level' => 'error'], // 沒掃碼到手環
|
||
'040F' => ['label' => 'Bracelet drop not detected', 'level' => 'error'], // 沒檢測到手環掉入取貨斗
|
||
|
||
// --- 取貨口門 (購物車模式) ---
|
||
'0433' => ['label' => 'Pickup door opened (cart mode)', 'level' => 'info'], // 取貨口門打開成功 (購物車購買)
|
||
'0434' => ['label' => 'Pickup door closed (cart mode)', 'level' => 'info'], // 取貨口門關閉成功 (購物車購買)
|
||
'0441' => ['label' => 'Pickup door unlocked', 'level' => 'info'], // 取貨口門開鎖上報
|
||
|
||
// --- 液體機專用 ---
|
||
'0435' => ['label' => 'Liquid dispense paused', 'level' => 'warning'], // 暫停出液
|
||
|
||
// --- 終止 ---
|
||
'04FF' => ['label' => 'Purchase terminated', 'level' => 'error'], // 購買終止
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 貨道與馬達狀態類 (Prefix: 02 - SLOT_STATUS)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
'0201' => ['label' => 'Slot normal', 'level' => 'info'], // 貨道正常
|
||
'0202' => ['label' => 'Slot out of stock', 'level' => 'warning'], // 貨道缺貨
|
||
'0203' => ['label' => 'Slot does not exist', 'level' => 'warning'], // 貨道不存在
|
||
'0204' => ['label' => 'Slot suspended', 'level' => 'warning'], // 貨道暫停使用
|
||
'0205' => ['label' => 'Elevator contains items', 'level' => 'warning'], // 升降台裡面有物品
|
||
'0206' => ['label' => 'Pickup door not closed', 'level' => 'warning'], // 取貨口門沒關上
|
||
'0207' => ['label' => 'Elevator failure (0207)', 'level' => 'error'], // 升降機故障
|
||
'0208' => ['label' => 'Elevator self-check error', 'level' => 'error'], // 升降機自檢錯誤
|
||
'0209' => ['label' => 'Microwave pickup door close error (0209)', 'level' => 'error'], // 微波爐取貨口門關閉錯誤
|
||
'020B' => ['label' => 'Machine temperature exceeds limit', 'level' => 'warning'], // 機器超過設定溫度,暫停購買
|
||
'020C' => ['label' => 'Bento machine cooling water tank empty', 'level' => 'error'], // 盒飯機冷卻水箱缺水
|
||
'020D' => ['label' => 'Age verification required', 'level' => 'warning'], // 貨道需要年齡驗證
|
||
'020E' => ['label' => 'Water level too low', 'level' => 'warning'], // 水量過低
|
||
'020F' => ['label' => 'Weight sensor communication error', 'level' => 'error'], // 重量傳感器通訊錯誤
|
||
'0210' => ['label' => 'Microwave delivery door open error (0210)', 'level' => 'error'],// 微波爐進貨口門打開錯誤
|
||
'0211' => ['label' => 'Microwave delivery door close error (0211)', 'level' => 'error'],// 微波爐進貨口門關閉錯誤
|
||
'0212' => ['label' => 'Bento not detected', 'level' => 'warning'], // 沒檢測到盒飯
|
||
'0213' => ['label' => 'Bento heating', 'level' => 'info'], // 盒飯正在加熱
|
||
'0214' => ['label' => 'Microwave pickup door open error (0214)', 'level' => 'error'], // 微波爐取貨口門打開錯誤
|
||
'0215' => ['label' => 'Please remove bento from microwave', 'level' => 'warning'], // 請取出微波爐內盒飯
|
||
'0216' => ['label' => 'Support rod return error', 'level' => 'error'], // 撐桿回位錯誤
|
||
'0217' => ['label' => 'Main motor failure', 'level' => 'error'], // 主電機故障
|
||
'0218' => ['label' => 'Translation motor failure', 'level' => 'error'], // 平移電機故障
|
||
'0219' => ['label' => 'Support rod push error', 'level' => 'error'], // 撐桿推出錯誤
|
||
'0220' => ['label' => 'Elevator enter microwave error (0220)', 'level' => 'error'], // 升降台進去微波爐錯誤
|
||
'0221' => ['label' => 'Elevator exit microwave error (0221)', 'level' => 'error'], // 升降台出去微波爐錯誤
|
||
'0222' => ['label' => 'Microwave push rod push error (0222)', 'level' => 'error'], // 微波爐內推桿推出錯誤
|
||
'0223' => ['label' => 'Microwave push rod retract error (0223)', 'level' => 'error'], // 微波爐內推桿收回錯誤
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 機台整體狀態類 (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'],
|
||
];
|
||
|
||
/**
|
||
* 出貨成功結果碼 (Dispense Success Codes)
|
||
*
|
||
* 僅以下代碼視為出貨成功,其餘出貨結果碼均應觸發退款。
|
||
* - 0402: 一般出貨成功
|
||
* - 0424: 商品取出成功 (微波爐機型)
|
||
*/
|
||
public const DISPENSE_SUCCESS_CODES = ['0402', '0424'];
|
||
|
||
/**
|
||
* 刷卡機 (Nexsys 終端機 / NCCC 標準) 交易回應碼對照表。
|
||
* 來源:聯合信用卡處理中心終端機回應碼規格。
|
||
* 用於把 order.payment_response 內的 "| code=XXXX" 解析成可讀(可多語)的失敗原因,
|
||
* 寫入 type='card_terminal' 機台日誌。信用卡(1)/電子票證(2)/手機支付(10) 共用同一台實體刷卡機。
|
||
* 值為英文翻譯鍵,實際顯示由 lang/*.json 多語系翻譯。
|
||
*/
|
||
public const CARD_TERMINAL_CODE_MAP = [
|
||
'0000' => 'Transaction approved',
|
||
'0001' => 'Transaction failed (issuer/card declined)',
|
||
'0002' => 'Please contact bank / e-wallet provider',
|
||
'0003' => 'Transaction timeout',
|
||
'0004' => 'Operation error',
|
||
'0005' => 'Communication failure',
|
||
'0006' => 'User terminated transaction',
|
||
'0009' => 'Non-participating institution card',
|
||
'0010' => 'Message format error',
|
||
'0011' => 'Not a co-branded card',
|
||
'0012' => 'Stored-value amount over limit',
|
||
'0013' => 'Card locked',
|
||
'0014' => 'Cannot match original stored-value transaction',
|
||
'0015' => 'Card auto-load not enabled',
|
||
'0016' => 'Insufficient stored-value / e-wallet balance',
|
||
'0017' => 'Multiple cards detected',
|
||
'0018' => 'Please re-tap card / re-scan / retry',
|
||
'0019' => 'Auto-load succeeded but purchase failed',
|
||
'1001' => 'Credit card and stored-value settlement both failed',
|
||
'1002' => 'Credit card settled, stored-value settlement failed',
|
||
'1003' => 'Credit card settlement failed, stored-value settled',
|
||
];
|
||
|
||
/**
|
||
* 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 關聯單據 Model(nullable)
|
||
* @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;
|
||
}
|
||
|
||
// 根據機台貨道機制設定決定上限與貨道類型 (1: 履帶, 2: 彈簧)
|
||
$slotType = $this->resolveSlotType($machine, $slotNo);
|
||
if ($actualProductId) {
|
||
$product = $products->find($actualProductId);
|
||
if ($product) {
|
||
$slotData['capacity'] = $this->calculateSlotMaxStock($product, $machine, $slotNo);
|
||
}
|
||
}
|
||
|
||
$newStock = (int) ($slotData['stock'] ?? 0);
|
||
|
||
$updateData = [
|
||
'product_id' => $actualProductId,
|
||
'type' => $slotType,
|
||
'stock' => $newStock,
|
||
'max_stock' => $slotData['capacity'] ?? ($existingSlot->max_stock ?? 10),
|
||
'is_active' => true,
|
||
];
|
||
|
||
if ($existingSlot) {
|
||
$oldStock = (int) $existingSlot->stock;
|
||
$oldProductId = $existingSlot->product_id;
|
||
|
||
// 檢查是否有實質變動 (Check for actual changes)
|
||
$isStockChanged = ($oldStock !== $newStock);
|
||
$isProductChanged = ($oldProductId != $actualProductId); // 這裡用鬆散比對以處理 null/0 的情況
|
||
$isSlotConfigChanged = (string) $existingSlot->type !== (string) $updateData['type']
|
||
|| (int) $existingSlot->max_stock !== (int) $updateData['max_stock'];
|
||
|
||
if ($isStockChanged || $isProductChanged || $isSlotConfigChanged) {
|
||
$existingSlot->update($updateData);
|
||
$existingSlot->refresh();
|
||
|
||
if ($isStockChanged || $isProductChanged) {
|
||
$delta = $newStock - $oldStock;
|
||
$note = $isProductChanged ? "movement.note.product_changed_and_adjusted" : "movement.note.replenishment_correction";
|
||
|
||
$this->recordStockMovement(
|
||
$existingSlot,
|
||
$delta,
|
||
MachineStockMovement::TYPE_ADJUSTMENT,
|
||
null,
|
||
$note,
|
||
['slot_no' => $existingSlot->slot_no, 'old' => $oldStock, 'new' => $newStock, 'old_product_id' => $oldProductId, 'new_product_id' => $actualProductId]
|
||
);
|
||
}
|
||
}
|
||
} else {
|
||
$newSlot = $machine->slots()->create(array_merge($updateData, ['slot_no' => $slotNo]));
|
||
|
||
// 新建貨道若有初始庫存,也紀錄流水帳
|
||
if ($newStock > 0) {
|
||
$this->recordStockMovement(
|
||
$newSlot,
|
||
$newStock,
|
||
MachineStockMovement::TYPE_ADJUSTMENT,
|
||
null,
|
||
"movement.note.initial_sync_report",
|
||
['slot_no' => $slotNo, 'old' => 0, 'new' => $newStock]
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── B009 全量同步:移除不在清單中的貨道 ───
|
||
// 取得本次上報的所有貨道編號
|
||
$reportedSlotNos = collect($slotsData)
|
||
->pluck('slot_no')
|
||
->filter()
|
||
->map(fn($s) => (string) $s)
|
||
->values()
|
||
->toArray();
|
||
|
||
// 找出資料庫中屬於此機台但不在清單內的舊貨道
|
||
$orphanedSlots = $machine->slots()
|
||
->whereNotIn('slot_no', $reportedSlotNos)
|
||
->get();
|
||
|
||
foreach ($orphanedSlots as $orphan) {
|
||
$oldStock = (int) $orphan->stock;
|
||
|
||
if ($oldStock > 0) {
|
||
// 先將 stock 設為 0 以便 recordStockMovement 正確計算 before/after
|
||
$orphan->stock = 0;
|
||
|
||
$this->recordStockMovement(
|
||
$orphan,
|
||
-$oldStock,
|
||
MachineStockMovement::TYPE_DECOMMISSION,
|
||
null,
|
||
"movement.note.slot_decommissioned_by_b009",
|
||
['old' => $oldStock, 'new' => 0, 'slot_no' => $orphan->slot_no]
|
||
);
|
||
}
|
||
|
||
// 更新貨道狀態為非使用中或直接刪除 (依據需求決定,目前採刪除以維持 B009 全量同步語意)
|
||
$orphan->delete();
|
||
}
|
||
});
|
||
}
|
||
|
||
public function syncProductSlotMaxStock(Product $product): void
|
||
{
|
||
MachineSlot::query()
|
||
->where('product_id', $product->id)
|
||
->with(['machine' => fn($query) => $query->withoutGlobalScopes()])
|
||
->chunkById(500, function ($slots) use ($product) {
|
||
foreach ($slots as $slot) {
|
||
if (!$slot->machine) {
|
||
continue;
|
||
}
|
||
|
||
$slot->update([
|
||
'type' => $this->resolveSlotType($slot->machine, $slot->slot_no),
|
||
'max_stock' => $this->calculateSlotMaxStock($product, $slot->machine, $slot->slot_no),
|
||
]);
|
||
}
|
||
});
|
||
}
|
||
|
||
public function syncMachineSlotMaxStock(Machine $machine): void
|
||
{
|
||
$machine->slots()
|
||
->with(['product' => fn($query) => $query->withoutGlobalScopes()])
|
||
->chunkById(500, function ($slots) use ($machine) {
|
||
foreach ($slots as $slot) {
|
||
if (!$slot->product) {
|
||
continue;
|
||
}
|
||
|
||
$slot->update([
|
||
'type' => $this->resolveSlotType($machine, $slot->slot_no),
|
||
'max_stock' => $this->calculateSlotMaxStock($slot->product, $machine, $slot->slot_no),
|
||
]);
|
||
}
|
||
});
|
||
}
|
||
|
||
public function calculateSlotMaxStock(Product $product, Machine $machine, string|int|null $slotNo): int
|
||
{
|
||
return $this->isSpringSlot($machine, $slotNo)
|
||
? (int) $product->spring_limit
|
||
: (int) $product->track_limit;
|
||
}
|
||
|
||
public function resolveSlotType(Machine $machine, string|int|null $slotNo): string
|
||
{
|
||
return $this->isSpringSlot($machine, $slotNo) ? '2' : '1';
|
||
}
|
||
|
||
public function isSpringSlot(Machine $machine, string|int|null $slotNo): bool
|
||
{
|
||
$slotNumber = (int) $slotNo;
|
||
|
||
foreach (self::SPRING_SLOT_FLAGS as [$start, $end, $field]) {
|
||
if ($slotNumber >= $start && $slotNumber <= $end) {
|
||
return (bool) $machine->{$field};
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
|
||
$pendingCommand = RemoteCommand::where('machine_id', $machine->id)
|
||
->where('command_type', 'reload_stock')
|
||
->where('status', 'pending')
|
||
->whereJsonContains('payload->slot_no', (string)$slotNo)
|
||
->latest()
|
||
->first();
|
||
|
||
if ($pendingCommand) {
|
||
// 直接覆蓋:將前一個尚未執行的指令標記為「已取代」
|
||
$pendingCommand->update([
|
||
'status' => 'superseded',
|
||
'note' => __('Superseded by new command'),
|
||
]);
|
||
}
|
||
|
||
// 紀錄舊數據以供回滾使用
|
||
$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",
|
||
['slot_no' => $slot->slot_no, '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 = [
|
||
'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, ?string $remark = null): RemoteCommand
|
||
{
|
||
return DB::transaction(function () use ($machine, $slotNo, $userId, $remark) {
|
||
$slot = $machine->slots()->where('slot_no', $slotNo)->lockForUpdate()->firstOrFail();
|
||
|
||
// 併行檢查:若有 pending 指令,超過 1 分鐘視為逾時可覆蓋
|
||
$pendingCommand = RemoteCommand::where('machine_id', $machine->id)
|
||
->where('command_type', 'dispense')
|
||
->where('status', 'pending')
|
||
->whereJsonContains('payload->slot_no', (string)$slotNo)
|
||
->latest()
|
||
->first();
|
||
|
||
if ($pendingCommand) {
|
||
$isTimedOut = $pendingCommand->created_at->lt(now()->subMinute());
|
||
|
||
if (!$isTimedOut) {
|
||
throw new \Exception(__('This slot has a pending command. Please wait.'));
|
||
}
|
||
|
||
// 超過 1 分鐘:視為逾時,自動標記舊指令
|
||
$pendingCommand->update([
|
||
'status' => 'timeout',
|
||
'note' => __('Superseded by new command (Timeout)'),
|
||
]);
|
||
}
|
||
|
||
if ($slot->stock <= 0) {
|
||
throw new \Exception(__('Out of stock.'));
|
||
}
|
||
|
||
// 1. 建立指令紀錄 (不在此處扣庫存,等候交易結案上報)
|
||
$command = RemoteCommand::create([
|
||
'machine_id' => $machine->id,
|
||
'user_id' => $userId,
|
||
'command_type' => 'dispense',
|
||
'status' => 'pending',
|
||
'remark' => $remark,
|
||
'payload' => [
|
||
'slot_no' => (string)$slotNo,
|
||
'current_stock' => $slot->stock,
|
||
]
|
||
]);
|
||
|
||
// 2. 推播 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,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 取得艦隊整體統計(批次查詢,無 N+1)
|
||
*/
|
||
public function getFleetStats(string $date): array
|
||
{
|
||
$start = Carbon::parse($date)->startOfDay();
|
||
$end = Carbon::parse($date)->endOfDay();
|
||
|
||
$machines = Machine::all(); // TenantScoped 已過濾
|
||
$machineIds = $machines->pluck('id')->toArray();
|
||
$totalMachines = $machines->count();
|
||
$onlineCount = Machine::online()->count();
|
||
|
||
// 今日成功訂單:批次查詢,非逐台
|
||
$ordersData = Order::whereIn('machine_id', $machineIds)
|
||
->where('payment_status', 1)
|
||
->whereBetween('created_at', [$start, $end])
|
||
->selectRaw('COUNT(*) as total_count, COALESCE(SUM(pay_amount), 0) as total_revenue')
|
||
->first();
|
||
|
||
// 今日異常次數
|
||
$alertCount = MachineLog::whereIn('machine_id', $machineIds)
|
||
->where('level', 'error')
|
||
->whereBetween('created_at', [$start, $end])
|
||
->count();
|
||
|
||
return [
|
||
'onlineCount' => $onlineCount,
|
||
'totalMachines' => $totalMachines,
|
||
'totalOrders' => (int)($ordersData->total_count ?? 0),
|
||
'totalRevenue' => (float)($ordersData->total_revenue ?? 0),
|
||
'alertCount' => $alertCount,
|
||
// OEE / avgUptime 已移除,online/offline 直接從 status 讀取
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 取得單台機台詳細統計(接入真實 orders 資料)
|
||
*/
|
||
public function getUtilizationStats(Machine $machine, string $date): array
|
||
{
|
||
$start = Carbon::parse($date)->startOfDay();
|
||
$end = Carbon::parse($date)->endOfDay();
|
||
|
||
// 1. 今日成功訂單:筆數 + 金額
|
||
$ordersAgg = $machine->orders()
|
||
->where('payment_status', 1)
|
||
->whereBetween('created_at', [$start, $end])
|
||
->selectRaw('COUNT(*) as cnt, COALESCE(SUM(pay_amount), 0) as rev')
|
||
->first();
|
||
|
||
$salesCount = (int)($ordersAgg->cnt ?? 0);
|
||
$revenue = (float)($ordersAgg->rev ?? 0);
|
||
|
||
// 2. 今日異常次數(dispense_records 失敗 或 machine_logs error)
|
||
$errorCount = $machine->logs()
|
||
->where('level', 'error')
|
||
->whereBetween('created_at', [$start, $end])
|
||
->count();
|
||
|
||
// 3. 逐小時銷售量(Bar Chart 用)
|
||
$hourlySales = $machine->orders()
|
||
->where('payment_status', 1)
|
||
->whereBetween('created_at', [$start, $end])
|
||
->selectRaw('HOUR(created_at) as hour, COUNT(*) as count')
|
||
->groupBy('hour')
|
||
->pluck('count', 'hour');
|
||
|
||
$labels = [];
|
||
$values = [];
|
||
for ($h = 0; $h < 24; $h++) {
|
||
$labels[] = sprintf('%02d:00', $h);
|
||
$values[] = (int)($hourlySales[$h] ?? 0);
|
||
}
|
||
|
||
// Flatten 格式,前端直接 spread 使用
|
||
return [
|
||
'output_count' => $salesCount,
|
||
'revenue' => round($revenue, 0),
|
||
'error_count' => $errorCount,
|
||
'chart_data' => [
|
||
'labels' => $labels,
|
||
'values' => $values,
|
||
],
|
||
];
|
||
}
|
||
}
|