1. 優化 MachineService@syncSlots,加入庫存與商品 ID 的實質變動檢查。 2. 解決在庫存與商品皆無變動時,仍會執行資料庫更新並產生冗餘流水帳的問題。 3. 強化商品變更判斷,確保更換商品時能正確記錄流水帳與對應備註。
560 lines
22 KiB
PHP
560 lines
22 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\Transaction\Order;
|
||
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 關聯單據 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;
|
||
}
|
||
|
||
// 根據貨道類型自動決定上限 (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 = (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 的情況
|
||
|
||
if ($isStockChanged || $isProductChanged) {
|
||
$existingSlot->update($updateData);
|
||
$existingSlot->refresh();
|
||
|
||
$delta = $newStock - $oldStock;
|
||
$note = $isProductChanged ? "movement.note.product_changed_and_adjusted" : "movement.note.replenishment_correction";
|
||
|
||
$this->recordStockMovement(
|
||
$existingSlot,
|
||
$delta,
|
||
MachineStockMovement::TYPE_ADJUSTMENT,
|
||
null,
|
||
$note,
|
||
['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",
|
||
['old' => 0, 'new' => $newStock]
|
||
);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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 = [
|
||
'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,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 取得艦隊整體統計(批次查詢,無 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,
|
||
],
|
||
];
|
||
}
|
||
}
|