intentParser = $intentParser ?? new system_search_openai_intent_parser(); } public function search(array $options): array { $query = trim((string)($options['query'] ?? '')); $includeTypes = $this->normalizeTypes((array)($options['include_types'] ?? [])); $excludeTypes = $this->normalizeTypes((array)($options['exclude_types'] ?? [])); $allowedTypes = $this->normalizeTypes((array)($options['allowed_types'] ?? [])); $ownOnlyTypes = $this->normalizeTypes((array)($options['own_only_types'] ?? [])); $ownCustomerNumber = isset($options['own_customer_number']) ? (int)$options['own_customer_number'] : null; $permissionsCatalogAll = (array)($options['permissions_catalog_all'] ?? []); $permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []); $moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []); $includeAssociations = (bool)($options['include_associations'] ?? true); $debugIntent = (bool)($options['debug_intent'] ?? false); $limit = (int)($options['limit'] ?? 50); $offset = (int)($options['offset'] ?? 0); if ($limit < 1) { $limit = 50; } if ($limit > 200) { $limit = 200; } if ($offset < 0) { $offset = 0; } $allTypes = $this->allEntityTypes(); $activeTypes = empty($includeTypes) ? $allTypes : array_values(array_intersect($allTypes, $includeTypes)); if (!empty($excludeTypes)) { $activeTypes = array_values(array_diff($activeTypes, $excludeTypes)); } $activeTypes = array_values(array_intersect($activeTypes, $allowedTypes)); $baseMeta = [ 'query' => $query, 'limit' => $limit, 'offset' => $offset, 'allowed_types' => $activeTypes, 'cache' => ['hit' => false], ]; if ($query === '' || empty($activeTypes)) { return [ 'results' => [], 'grouped_results' => $this->groupResultsByType([]), 'meta' => [ ...$baseMeta, 'total' => 0, ], ]; } $queryCacheHash = md5(json_encode([ 'q' => $query, 'include' => $includeTypes, 'exclude' => $excludeTypes, 'active' => $activeTypes, 'limit' => $limit, 'offset' => $offset, 'own' => $ownCustomerNumber, 'own_only' => $ownOnlyTypes, 'assoc' => $includeAssociations, 'dbg' => $debugIntent, 'v' => 2, ], JSON_UNESCAPED_UNICODE)); $cached = system_search_cache::getQuery($queryCacheHash); if (is_array($cached) && isset($cached['results'], $cached['grouped_results'], $cached['meta'])) { $cached['meta']['cache'] = ['hit' => true]; return $cached; } $terms = $this->tokenize($query); $entityBoost = []; $initialResults = $this->executeLexicalSearch( $activeTypes, $terms, $entityBoost, $ownOnlyTypes, $ownCustomerNumber, $permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility ); $intentMeta = [ 'invoked' => false, 'source' => 'none', 'status' => 'skipped', 'confidence' => 0.0, 'expanded_terms' => $terms, 'entity_hints' => [], 'fallback_reason' => null, ]; if ($this->shouldInvokeIntentParser($initialResults) && !empty($terms)) { $intentMeta['invoked'] = true; $taxonomy = $this->taxonomy($activeTypes); $intent = $this->intentParser->parse($query, $activeTypes, $taxonomy); $intentMeta['source'] = (string)($intent['source'] ?? 'none'); $intentMeta['confidence'] = (float)($intent['confidence'] ?? 0.0); $intentMeta['fallback_reason'] = $intent['fallback_reason'] ?? null; $intentMeta['entity_hints'] = (array)($intent['entity_hints'] ?? []); if (!empty($intent['success'])) { $intentMeta['status'] = 'ok'; $expandedTerms = array_values(array_unique([ ...$terms, ...$this->tokenize((string)($intent['normalized_query'] ?? '')), ...$this->tokenize(implode(' ', (array)($intent['aliases'] ?? []))), ])); $intentMeta['expanded_terms'] = $expandedTerms; $boostedTypes = array_values(array_intersect($activeTypes, (array)($intent['entity_hints'] ?? []))); foreach ($boostedTypes as $boostedType) { $entityBoost[$boostedType] = 25; } $initialResults = $this->executeLexicalSearch( $activeTypes, $expandedTerms, $entityBoost, $ownOnlyTypes, $ownCustomerNumber, $permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility ); } else { $intentMeta['status'] = 'fallback'; } } if ($includeAssociations) { $customerNumbers = []; foreach ($initialResults as $result) { if ($result['entity_type'] === 'customers' && isset($result['customer_number'])) { $customerNumbers[] = (int)$result['customer_number']; } } $customerNumbers = array_values(array_unique(array_filter($customerNumbers))); if (!empty($customerNumbers)) { $associationTypes = array_values(array_intersect( $activeTypes, ['orders', 'order_items', 'invoices', 'vehicles', 'customer_discounts', 'customer_fixed_prices'] )); foreach ($customerNumbers as $customerNumber) { $associated = $this->executeLexicalSearch( $associationTypes, [(string)$customerNumber], [], $ownOnlyTypes, $ownCustomerNumber, $permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility, [$customerNumber] ); foreach ($associated as &$item) { if (!isset($item['association_reason'])) { $item['association_reason'] = 'customer:' . $customerNumber; } $item['score'] = max((int)$item['score'], 35); } $initialResults = $this->mergeResults($initialResults, $associated); } } } usort($initialResults, function (array $a, array $b): int { if ($a['score'] === $b['score']) { return strcmp((string)$a['entity_type'] . ':' . (string)$a['entity_id'], (string)$b['entity_type'] . ':' . (string)$b['entity_id']); } return $b['score'] <=> $a['score']; }); $total = count($initialResults); $paged = array_slice($initialResults, $offset, $limit); $grouped = $this->groupResultsByType($paged); $meta = [ ...$baseMeta, 'total' => $total, ]; if ($debugIntent) { $meta['intent_parser'] = $intentMeta; } $payload = [ 'results' => $paged, 'grouped_results' => $grouped, 'meta' => $meta, ]; system_search_cache::setQuery($queryCacheHash, $payload, 120); return $payload; } protected function shouldInvokeIntentParser(array $results): bool { if (count($results) < $this->lowConfidenceResultThreshold) { return true; } $topScore = (int)($results[0]['score'] ?? 0); return $topScore < $this->lowConfidenceTopScoreThreshold; } /** * @param array $activeTypes * @param array $terms * @param array $entityBoost * @param array $ownOnlyTypes * @param int|null $ownCustomerNumber * @param array $permissionsCatalogAll * @param array $permissionsCatalogOwn * @param array $moduleConfigVisibility * @param array $forcedCustomerNumbers * @return array> */ protected function executeLexicalSearch( array $activeTypes, array $terms, array $entityBoost, array $ownOnlyTypes, ?int $ownCustomerNumber, array $permissionsCatalogAll, array $permissionsCatalogOwn, array $moduleConfigVisibility, array $forcedCustomerNumbers = [] ): array { $results = []; foreach ($activeTypes as $entityType) { $boost = (int)($entityBoost[$entityType] ?? 0); $ownOnly = in_array($entityType, $ownOnlyTypes, true); $rows = $this->searchEntity( $entityType, $terms, $boost, $ownOnly, $ownCustomerNumber, $permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility, $forcedCustomerNumbers ); $results = $this->mergeResults($results, $rows); } return $results; } /** * @param array $terms * @param array $permissionsCatalogAll * @param array $permissionsCatalogOwn * @param array $moduleConfigVisibility * @param array $forcedCustomerNumbers * @return array> */ private function searchEntity( string $entityType, array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $permissionsCatalogAll, array $permissionsCatalogOwn, array $moduleConfigVisibility, array $forcedCustomerNumbers ): array { return match ($entityType) { 'customers' => $this->searchCustomers($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers), 'employees' => $this->searchEmployees($terms, $entityBoost, $ownOnly, $ownCustomerNumber), 'orders' => $this->searchOrders($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers), 'order_items' => $this->searchOrderItems($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers), 'invoices' => $this->searchInvoices($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers), 'vehicles' => $this->searchVehicles($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers), 'subusers' => $this->searchSubusers($terms, $entityBoost, $ownOnly, $ownCustomerNumber), 'customer_discounts' => $this->searchCustomerDiscounts($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers), 'customer_fixed_prices' => $this->searchCustomerFixedPrices($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers), 'departments' => $this->searchDepartments($terms, $entityBoost), 'roles' => $this->searchRoles($terms, $entityBoost), 'permissions' => $this->searchPermissions($terms, $entityBoost, $ownOnly, $permissionsCatalogAll, $permissionsCatalogOwn), 'module_config' => $this->searchModuleConfig($terms, $entityBoost, $moduleConfigVisibility), 'objects' => $this->searchObjects($terms, $entityBoost, $ownOnly, $ownCustomerNumber), default => [], }; } private function searchCustomers(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array { $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : ($ownOnly && $ownCustomerNumber ? [$ownCustomerNumber] : []); $rows = $this->searchTable( 'users', ['id', 'customer_number', 'display_name', 'email', 'phone'], ['id', 'customer_number', 'display_name', 'email', 'phone'], $terms, $customerNumbers, 'customer_number' ); return array_map(function (array $row) use ($terms, $entityBoost) { return [ 'entity_type' => 'customers', 'entity_id' => (string)$row['id'], 'title' => (string)($row['display_name'] ?: ('Customer #' . $row['customer_number'])), 'description' => (string)($row['email'] ?? ''), 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'score' => $this->scoreRow($row, ['customer_number', 'display_name', 'email', 'phone'], $terms) + $entityBoost, 'payload' => [ 'id' => (int)$row['id'], 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'display_name' => $row['display_name'] ?? null, 'email' => $row['email'] ?? null, 'phone' => $row['phone'] ?? null, ], ]; }, $rows); } private function searchEmployees(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array { $rows = $this->searchTableWithJoin( 'users', 'users u INNER JOIN groups_permissions gp ON gp.group_id = u.group_id', ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'], ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'], $terms, "gp.permission = 'employee_public_data'" . (($ownOnly && $ownCustomerNumber) ? (' AND u.customer_number = ' . (int)$ownCustomerNumber) : '') ); return array_map(function (array $row) use ($terms, $entityBoost) { return [ 'entity_type' => 'employees', 'entity_id' => (string)$row['id'], 'title' => (string)($row['display_name'] ?: ('Employee #' . $row['id'])), 'description' => (string)($row['email'] ?? ''), 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'score' => $this->scoreRow($row, ['display_name', 'email', 'phone', 'customer_number'], $terms) + $entityBoost, 'payload' => [ 'id' => (int)$row['id'], 'display_name' => $row['display_name'] ?? null, 'email' => $row['email'] ?? null, 'phone' => $row['phone'] ?? null, ], ]; }, $rows); } private function searchOrders(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array { $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : ($ownOnly && $ownCustomerNumber ? [$ownCustomerNumber] : []); $rows = $this->searchTable( 'orders', ['id', 'customer_id', 'reference', 'notes', 'reg_1', 'reg_2', 'reg_3', 'department_id', 'po', 'deleted_at'], ['id', 'customer_id', 'reference', 'notes', 'reg_1', 'reg_2', 'reg_3', 'po'], $terms, $customerNumbers, 'customer_id', ['deleted_at' => null] ); return array_map(function (array $row) use ($terms, $entityBoost) { return [ 'entity_type' => 'orders', 'entity_id' => (string)$row['id'], 'title' => 'Order #' . (string)$row['id'], 'description' => (string)($row['reference'] ?? ''), 'customer_number' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, 'department_id' => isset($row['department_id']) ? (int)$row['department_id'] : null, 'score' => $this->scoreRow($row, ['id', 'customer_id', 'reference', 'notes', 'reg_1', 'reg_2', 'reg_3', 'po'], $terms) + $entityBoost, 'payload' => [ 'id' => (int)$row['id'], 'customer_id' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, 'reference' => $row['reference'] ?? null, 'notes' => $row['notes'] ?? null, 'reg_1' => $row['reg_1'] ?? null, ], ]; }, $rows); } private function searchOrderItems(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array { $customerClause = ''; if (!empty($forcedCustomerNumbers)) { $customerClause = ' AND o.customer_id IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')'; } elseif ($ownOnly && $ownCustomerNumber) { $customerClause = ' AND o.customer_id = ' . (int)$ownCustomerNumber; } $baseWhere = 'o.id = oi.order_id' . $customerClause . ' AND o.deleted_at IS NULL'; $rows = $this->searchTableWithJoin( 'order_items', 'order_items oi INNER JOIN orders o ON o.id = oi.order_id', ['oi.id', 'oi.order_id', 'oi.product_id', 'oi.reference', 'oi.notes', 'o.customer_id AS customer_number'], ['oi.id', 'oi.order_id', 'oi.product_id', 'oi.reference', 'oi.notes', 'o.customer_id'], $terms, $baseWhere ); return array_map(function (array $row) use ($terms, $entityBoost) { return [ 'entity_type' => 'order_items', 'entity_id' => (string)$row['id'], 'title' => 'Order item #' . (string)$row['id'], 'description' => (string)($row['reference'] ?? ''), 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'score' => $this->scoreRow($row, ['id', 'order_id', 'product_id', 'reference', 'notes', 'customer_number'], $terms) + $entityBoost, 'payload' => [ 'id' => (int)$row['id'], 'order_id' => isset($row['order_id']) ? (int)$row['order_id'] : null, 'product_id' => isset($row['product_id']) ? (int)$row['product_id'] : null, 'reference' => $row['reference'] ?? null, ], ]; }, $rows); } private function searchInvoices(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array { $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : ($ownOnly && $ownCustomerNumber ? [$ownCustomerNumber] : []); $rows = $this->searchTable( 'collected_order_invoices', ['id', 'customer_number', 'name', 'notes', 'external_id', 'booked_invoice_id', 'po_number', 'deleted_at'], ['id', 'customer_number', 'name', 'notes', 'external_id', 'booked_invoice_id', 'po_number'], $terms, $customerNumbers, 'customer_number', ['deleted_at' => null] ); return array_map(function (array $row) use ($terms, $entityBoost) { return [ 'entity_type' => 'invoices', 'entity_id' => (string)$row['id'], 'title' => (string)($row['name'] ?: ('Invoice collection #' . $row['id'])), 'description' => (string)($row['external_id'] ?? ''), 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'score' => $this->scoreRow($row, ['id', 'customer_number', 'name', 'notes', 'external_id', 'booked_invoice_id', 'po_number'], $terms) + $entityBoost, 'payload' => [ 'id' => (int)$row['id'], 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'name' => $row['name'] ?? null, 'external_id' => $row['external_id'] ?? null, ], ]; }, $rows); } private function searchVehicles(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array { $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : ($ownOnly && $ownCustomerNumber ? [$ownCustomerNumber] : []); $rows = $this->searchTable( 'customer_vehicles', ['id', 'customer_id', 'reg', 'reference', 'type', 'deleted_at'], ['id', 'customer_id', 'reg', 'reference', 'type'], $terms, $customerNumbers, 'customer_id', ['deleted_at' => null] ); return array_map(function (array $row) use ($terms, $entityBoost) { return [ 'entity_type' => 'vehicles', 'entity_id' => (string)$row['id'], 'title' => (string)($row['reg'] ?: ('Vehicle #' . $row['id'])), 'description' => (string)($row['reference'] ?? ''), 'customer_number' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, 'score' => $this->scoreRow($row, ['id', 'customer_id', 'reg', 'reference', 'type'], $terms) + $entityBoost, 'payload' => [ 'id' => (int)$row['id'], 'customer_id' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, 'reg' => $row['reg'] ?? null, 'reference' => $row['reference'] ?? null, ], ]; }, $rows); } private function searchSubusers(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array { $where = '1=1'; if ($ownOnly && $ownCustomerNumber) { $where .= ' AND sg.billing_customer_number = ' . (int)$ownCustomerNumber . ' AND sg.deleted_at IS NULL'; } $rows = $this->searchTableWithJoin( 'subusers', 'subusers s LEFT JOIN subuser_grants sg ON sg.subuser = s.id', ['s.id', 's.username', 's.name', 's.email', 's.phone_country_code', 's.phone'], ['s.id', 's.username', 's.name', 's.email', 's.phone'], $terms, $where ); return array_map(function (array $row) use ($terms, $entityBoost) { return [ 'entity_type' => 'subusers', 'entity_id' => (string)$row['id'], 'title' => (string)($row['name'] ?: ($row['username'] ?? ('Subuser #' . $row['id']))), 'description' => (string)($row['email'] ?? ''), 'score' => $this->scoreRow($row, ['id', 'username', 'name', 'email', 'phone'], $terms) + $entityBoost, 'payload' => [ 'id' => (int)$row['id'], 'username' => $row['username'] ?? null, 'name' => $row['name'] ?? null, 'email' => $row['email'] ?? null, ], ]; }, $rows); } private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array { $customerFilter = ''; if (!empty($forcedCustomerNumbers)) { $customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')'; } elseif ($ownOnly && $ownCustomerNumber) { $customerFilter = ' AND u.customer_number = ' . (int)$ownCustomerNumber; } $rows = $this->searchTableWithJoin( 'price_overrides', 'price_overrides po INNER JOIN users u ON u.id = po.user_id', ['po.id', 'po.user_id', 'po.is_category', 'po.product_or_category_id', 'po.percentage', 'u.customer_number', 'u.display_name'], ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'u.customer_number', 'u.display_name'], $terms, '1=1' . $customerFilter ); return array_map(function (array $row) use ($terms, $entityBoost) { return [ 'entity_type' => 'customer_discounts', 'entity_id' => (string)$row['id'], 'title' => 'Discount #' . (string)$row['id'], 'description' => (string)('Customer ' . ($row['customer_number'] ?? '') . ' / ' . ($row['display_name'] ?? '')), 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'score' => $this->scoreRow($row, ['id', 'customer_number', 'display_name', 'product_or_category_id', 'percentage', 'user_id'], $terms) + $entityBoost, 'payload' => [ 'id' => (int)$row['id'], 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'product_or_category_id' => $row['product_or_category_id'] ?? null, 'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : null, ], ]; }, $rows); } private function searchCustomerFixedPrices(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array { $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : ($ownOnly && $ownCustomerNumber ? [$ownCustomerNumber] : []); $rows = $this->searchTable( 'customer_fixed_pricing', ['id', 'customer_number', 'price', 'description'], ['id', 'customer_number', 'price', 'description'], $terms, $customerNumbers, 'customer_number' ); return array_map(function (array $row) use ($terms, $entityBoost) { return [ 'entity_type' => 'customer_fixed_prices', 'entity_id' => (string)$row['id'], 'title' => 'Fixed pricing #' . (string)$row['id'], 'description' => (string)($row['description'] ?? ''), 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'score' => $this->scoreRow($row, ['id', 'customer_number', 'price', 'description'], $terms) + $entityBoost, 'payload' => [ 'id' => (int)$row['id'], 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'price' => isset($row['price']) ? (int)$row['price'] : null, 'description' => $row['description'] ?? null, ], ]; }, $rows); } private function searchDepartments(array $terms, int $entityBoost): array { $rows = $this->searchTable( 'departments', ['id', 'name', 'address', 'zip', 'city'], ['id', 'name', 'address', 'zip', 'city'], $terms ); return array_map(function (array $row) use ($terms, $entityBoost) { return [ 'entity_type' => 'departments', 'entity_id' => (string)$row['id'], 'title' => (string)($row['name'] ?: ('Department #' . $row['id'])), 'description' => trim((string)(($row['address'] ?? '') . ' ' . ($row['city'] ?? ''))), 'score' => $this->scoreRow($row, ['id', 'name', 'address', 'zip', 'city'], $terms) + $entityBoost, 'payload' => [ 'id' => (int)$row['id'], 'name' => $row['name'] ?? null, 'address' => $row['address'] ?? null, 'zip' => $row['zip'] ?? null, 'city' => $row['city'] ?? null, ], ]; }, $rows); } private function searchRoles(array $terms, int $entityBoost): array { $rows = $this->searchTable( 'groups', ['id', 'name', 'description'], ['id', 'name', 'description'], $terms ); return array_map(function (array $row) use ($terms, $entityBoost) { return [ 'entity_type' => 'roles', 'entity_id' => (string)$row['id'], 'title' => (string)($row['name'] ?: ('Role #' . $row['id'])), 'description' => (string)($row['description'] ?? ''), 'score' => $this->scoreRow($row, ['id', 'name', 'description'], $terms) + $entityBoost, 'payload' => [ 'id' => (int)$row['id'], 'name' => $row['name'] ?? null, 'description' => $row['description'] ?? null, ], ]; }, $rows); } private function searchPermissions(array $terms, int $entityBoost, bool $ownOnly, array $permissionsCatalogAll, array $permissionsCatalogOwn): array { $rows = []; if ($ownOnly) { foreach ($permissionsCatalogOwn as $permission) { $rows[] = ['permission' => (string)$permission, 'description' => (string)$permission]; } } else { foreach ($permissionsCatalogAll as $permission => $description) { $rows[] = ['permission' => (string)$permission, 'description' => (string)$description]; } } $filtered = []; foreach ($rows as $row) { $score = $this->scoreRow($row, ['permission', 'description'], $terms) + $entityBoost; if ($score <= 0) { continue; } $filtered[] = [ 'entity_type' => 'permissions', 'entity_id' => (string)$row['permission'], 'title' => (string)$row['permission'], 'description' => (string)$row['description'], 'score' => $score, 'payload' => $row, ]; } return $filtered; } private function searchModuleConfig(array $terms, int $entityBoost, array $moduleConfigVisibility): array { $rows = $this->searchTable( 'module_config', ['module', 'variable', 'type', 'value'], ['module', 'variable', 'type'], $terms ); $filtered = []; foreach ($rows as $row) { $module = (string)($row['module'] ?? ''); if ($module !== '' && isset($moduleConfigVisibility[$module]) && !$moduleConfigVisibility[$module]) { continue; } $variable = (string)($row['variable'] ?? ''); if ($this->looksSecretVariable($variable)) { continue; } $score = $this->scoreRow($row, ['module', 'variable', 'type'], $terms) + $entityBoost; if ($score <= 0) { continue; } $filtered[] = [ 'entity_type' => 'module_config', 'entity_id' => $module . ':' . $variable, 'title' => $module . '.' . $variable, 'description' => (string)($row['type'] ?? ''), 'score' => $score, 'payload' => [ 'module' => $module, 'variable' => $variable, 'type' => $row['type'] ?? null, ], ]; } return $filtered; } private function searchObjects(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array { if ($ownOnly && $ownCustomerNumber !== null) { return []; } $rows = $this->searchTable( 'object_attachments', ['id', 'object_type', 'object_id', 'content', 'deleted_at'], ['id', 'object_type', 'object_id', 'content'], $terms, [], null, ['deleted_at' => null] ); return array_map(function (array $row) use ($terms, $entityBoost) { return [ 'entity_type' => 'objects', 'entity_id' => (string)$row['id'], 'title' => (string)($row['object_type'] ?? 'object_attachment') . '#' . (string)$row['object_id'], 'description' => (string)$row['content'], 'score' => $this->scoreRow($row, ['id', 'object_type', 'object_id', 'content'], $terms) + $entityBoost, 'payload' => [ 'id' => (int)$row['id'], 'object_type' => $row['object_type'] ?? null, 'object_id' => $row['object_id'] ?? null, ], ]; }, $rows); } /** * Generic table search helper. * * @param array $candidateFields * @param array $searchFields * @param array $terms * @param array $customerNumbers * @param string|null $customerField * @param array $fixedConditions * @return array> */ private function searchTable( string $table, array $candidateFields, array $searchFields, array $terms, array $customerNumbers = [], ?string $customerField = null, array $fixedConditions = [] ): array { global $db; if (!$this->tableExists($table)) { return []; } $fields = $this->intersectExistingColumns($table, $candidateFields); if (empty($fields)) { return []; } $searchable = array_values(array_intersect($searchFields, $fields)); if (empty($searchable)) { return []; } $wheres = []; foreach ($fixedConditions as $column => $value) { if (!in_array($column, $fields, true)) { continue; } if ($value === null) { $wheres[] = "`$column` IS NULL"; } else { $wheres[] = "`$column` = '" . $db->escape_string((string)$value) . "'"; } } if (!empty($customerNumbers) && $customerField !== null && in_array($customerField, $fields, true)) { $wheres[] = "`$customerField` IN (" . implode(',', array_map('intval', $customerNumbers)) . ")"; } $termClauses = []; foreach ($terms as $term) { $escaped = $db->escape_string($term); foreach ($searchable as $field) { $termClauses[] = "`$field` LIKE '%$escaped%'"; } } if (!empty($termClauses)) { $wheres[] = '(' . implode(' OR ', $termClauses) . ')'; } if (empty($wheres)) { return []; } $sql = "SELECT " . implode(', ', array_map(fn($f) => "`$f`", $fields)) . " FROM `$table`" . " WHERE " . implode(' AND ', $wheres) . " LIMIT " . $this->defaultEntityFetchLimit; $result = $db->query($sql); if (!($result instanceof \mysqli_result)) { return []; } return $db->fetch_all($result); } /** * Generic join search helper. * * @param array $selectFields * @param array $searchFields * @param array $terms * @return array> */ private function searchTableWithJoin( string $table, string $fromClause, array $selectFields, array $searchFields, array $terms, string $baseWhere ): array { global $db; if (!$this->tableExists($table)) { return []; } if (empty($terms)) { return []; } $termClauses = []; foreach ($terms as $term) { $escaped = $db->escape_string($term); foreach ($searchFields as $field) { $termClauses[] = "$field LIKE '%$escaped%'"; } } if (empty($termClauses)) { return []; } $sql = "SELECT " . implode(', ', $selectFields) . " FROM " . $fromClause . " WHERE " . $baseWhere . " AND (" . implode(' OR ', $termClauses) . ")" . " LIMIT " . $this->defaultEntityFetchLimit; $result = $db->query($sql); if (!($result instanceof \mysqli_result)) { return []; } return $db->fetch_all($result); } private function scoreRow(array $row, array $fields, array $terms): int { $score = 0; foreach ($terms as $term) { $termLower = mb_strtolower($term); foreach ($fields as $field) { if (!array_key_exists($field, $row) || $row[$field] === null) { continue; } $value = trim((string)$row[$field]); if ($value === '') { continue; } $valueLower = mb_strtolower($value); if ($valueLower === $termLower) { $score += 100; continue; } if (str_starts_with($valueLower, $termLower)) { $score += 60; continue; } if (str_contains($valueLower, $termLower)) { $score += 30; continue; } if (strlen($termLower) >= 4 && strlen($valueLower) <= 64) { $distance = levenshtein($termLower, $valueLower); if ($distance <= 2) { $score += 20 - ($distance * 5); } } } } return $score; } private function tableExists(string $table): bool { return !empty($this->getColumns($table)); } private function intersectExistingColumns(string $table, array $candidateFields): array { $columns = $this->getColumns($table); if (empty($columns)) { return []; } return array_values(array_intersect($candidateFields, $columns)); } private function getColumns(string $table): array { if (isset($this->tableColumnsCache[$table])) { return $this->tableColumnsCache[$table]; } global $db; try { $result = $db->query("SHOW COLUMNS FROM `$table`"); if (!($result instanceof \mysqli_result)) { $this->tableColumnsCache[$table] = []; return []; } $rows = $db->fetch_all($result); $columns = array_values(array_map(static fn($row) => (string)$row['Field'], $rows)); $this->tableColumnsCache[$table] = $columns; return $columns; } catch (Throwable) { $this->tableColumnsCache[$table] = []; return []; } } private function looksSecretVariable(string $variable): bool { $variable = mb_strtolower($variable); return str_contains($variable, 'api_key') || str_contains($variable, 'secret') || str_contains($variable, 'password') || str_contains($variable, 'token') || str_contains($variable, 'private_key'); } private function mergeResults(array $base, array $incoming): array { $indexed = []; foreach ($base as $item) { $key = (string)$item['entity_type'] . ':' . (string)$item['entity_id']; $indexed[$key] = $item; } foreach ($incoming as $item) { $key = (string)$item['entity_type'] . ':' . (string)$item['entity_id']; if (!isset($indexed[$key])) { $indexed[$key] = $item; continue; } if ((int)$item['score'] > (int)$indexed[$key]['score']) { $indexed[$key]['score'] = (int)$item['score']; } if (!isset($indexed[$key]['association_reason']) && isset($item['association_reason'])) { $indexed[$key]['association_reason'] = $item['association_reason']; } } return array_values($indexed); } private function tokenize(string $query): array { $query = trim(mb_strtolower($query)); if ($query === '') { return []; } $parts = preg_split('/[^a-z0-9_]+/iu', $query) ?: []; $parts = array_values(array_filter(array_map('trim', $parts), static fn($p) => $p !== '' && mb_strlen($p) >= 2)); return array_values(array_unique($parts)); } private function normalizeTypes(array $types): array { $normalized = []; foreach ($types as $type) { if (!is_string($type)) { continue; } $t = trim(mb_strtolower($type)); if ($t === '') { continue; } $normalized[] = $t; } return array_values(array_unique($normalized)); } private function allEntityTypes(): array { return [ 'objects', 'module_config', 'orders', 'order_items', 'customers', 'employees', 'subusers', 'customer_discounts', 'customer_fixed_prices', 'departments', 'permissions', 'roles', 'invoices', 'vehicles', ]; } private function taxonomy(array $activeTypes): array { $aliases = [ 'customers' => ['customer', 'account', 'company'], 'orders' => ['order', 'work order'], 'order_items' => ['order item', 'line item'], 'invoices' => ['invoice', 'billing'], 'vehicles' => ['vehicle', 'truck', 'plate'], 'employees' => ['employee', 'staff'], 'subusers' => ['subuser', 'driver'], 'customer_discounts' => ['discount', 'price override'], 'customer_fixed_prices' => ['fixed price', 'monthly agreement'], 'departments' => ['department', 'location'], 'permissions' => ['permission', 'acl'], 'roles' => ['role', 'group'], 'module_config' => ['module config', 'setting', 'configuration'], 'objects' => ['attachment', 'object'], ]; $taxonomy = []; foreach ($activeTypes as $type) { $taxonomy[$type] = $aliases[$type] ?? []; } return $taxonomy; } private function groupResultsByType(array $results): array { $grouped = []; foreach ($results as $item) { $type = (string)$item['entity_type']; if (!isset($grouped[$type])) { $grouped[$type] = []; } $grouped[$type][] = $item; } return $grouped; } }