35, 'orders' => 35, 'order_bookings' => 35, 'customers' => 35, ]; private array $rankingPenaltyByType = [ 'xlvask_customers' => 90, 'xlvask_usage_logs' => 90, 'xlvask_vehicle_types' => 90, 'motorapi_lookups' => 90, 'customer_discounts' => 90, 'department_selfserve_vehicle_conditions' => 90, 'permissions' => 90, 'branding' => 90, 'order_items' => 90, 'module_config' => 90, ]; private array $tableColumnsCache = []; private array $customerContextCache = []; public function __construct() { try { system_search_economic_customer_index::ensureTable(); system_search_document_index::ensureTable(); } catch (Throwable) { // Search should work even if index bootstrap is temporarily unavailable. } } 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; $allowedDepartmentIds = array_values(array_unique(array_map('intval', (array)($options['allowed_department_ids'] ?? [])))); $permissionsCatalogAll = (array)($options['permissions_catalog_all'] ?? []); $permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []); $moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []); $includeAssociations = (bool)($options['include_associations'] ?? true); $maxResults = (int)($options['max_results'] ?? $this->defaultMaxResults); if ($maxResults < 1) { $maxResults = $this->defaultMaxResults; } if ($maxResults > $this->defaultMaxResults) { $maxResults = $this->defaultMaxResults; } $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)); $terms = $this->buildExpandedTerms($this->tokenize($query)); $activeTypes = $this->selectSearchTypes($activeTypes, $includeTypes, $terms, $query); $baseMeta = [ 'query' => $query, 'max_results' => $maxResults, 'allowed_types' => $activeTypes, 'cache' => ['hit' => false], ]; if ($query === '' || empty($activeTypes)) { return [ 'results' => [], 'grouped_results' => $this->groupResultsByType([]), 'meta' => [ ...$baseMeta, 'returned' => 0, 'truncated' => false, ], ]; } $queryCacheHash = md5(json_encode([ 'q' => $query, 'include' => $includeTypes, 'exclude' => $excludeTypes, 'active' => $activeTypes, 'max' => $maxResults, 'own' => $ownCustomerNumber, 'own_only' => $ownOnlyTypes, 'dept' => $allowedDepartmentIds, 'assoc' => $includeAssociations, 'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility), 'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)), 'v' => 13, ], 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; } $entityBoost = []; $initialResults = $this->executeLexicalSearch( $activeTypes, $terms, $entityBoost, $ownOnlyTypes, $ownCustomerNumber, $permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility, $allowedDepartmentIds ); $initialResults = $this->filterRelevantResults($initialResults, $includeTypes); if ($includeAssociations) { $customerNumbers = []; foreach ($initialResults as $result) { $customerNumber = $this->toIntOrNull($result['customer_number'] ?? null); if ($customerNumber === null || $customerNumber <= 0) { continue; } if (!$this->shouldExpandAssociationsFromResult($result, $includeTypes)) { continue; } $customerNumbers[] = $customerNumber; } $customerNumbers = array_values(array_unique(array_filter($customerNumbers))); if (count($customerNumbers) > 5) { $customerNumbers = array_slice($customerNumbers, 0, 5); } if (!empty($customerNumbers)) { $associationTypes = array_values(array_intersect( $activeTypes, $this->associationEntityTypes() )); $associationTypes = array_values(array_diff($associationTypes, $ownOnlyTypes)); if (!empty($associationTypes)) { foreach ($customerNumbers as $customerNumber) { $associated = $this->executeLexicalSearch( $associationTypes, [(string)$customerNumber], [], $ownOnlyTypes, $ownCustomerNumber, $permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility, $allowedDepartmentIds, [$customerNumber] ); foreach ($associated as &$item) { if (!isset($item['association_reason'])) { $item['association_reason'] = 'customer:' . $customerNumber; } $item['score'] = max((int)$item['score'], 35); } unset($item); $associated = $this->filterRelevantResults($associated, $includeTypes); $initialResults = $this->mergeResults($initialResults, $associated); } } } } $initialResults = $this->filterRelevantResults($initialResults, $includeTypes); $preferRecency = $this->shouldPreferRecencySort($query, $terms); usort($initialResults, function (array $a, array $b) use ($preferRecency): int { $scoreA = (int)($a['score'] ?? 0); $scoreB = (int)($b['score'] ?? 0); $effectiveScoreA = $this->effectiveResultScore($a); $effectiveScoreB = $this->effectiveResultScore($b); $recencyA = $this->resultRecencyTimestamp($a); $recencyB = $this->resultRecencyTimestamp($b); $cancelledA = $this->isCancelledBookingResult($a); $cancelledB = $this->isCancelledBookingResult($b); // Cancelled bookings must never outrank active bookings. if ($cancelledA !== $cancelledB) { return $cancelledA ? 1 : -1; } if ($preferRecency && $recencyA !== $recencyB) { if ($effectiveScoreA === $effectiveScoreB || abs($effectiveScoreA - $effectiveScoreB) <= $this->recencyScoreTolerance) { return $recencyB <=> $recencyA; } } if ($effectiveScoreA !== $effectiveScoreB) { return $effectiveScoreB <=> $effectiveScoreA; } if ($scoreA !== $scoreB) { return $scoreB <=> $scoreA; } if ($recencyA !== $recencyB) { return $recencyB <=> $recencyA; } return strcmp((string)$a['entity_type'] . ':' . (string)$a['entity_id'], (string)$b['entity_type'] . ':' . (string)$b['entity_id']); }); $limited = array_slice($initialResults, 0, $maxResults); $grouped = $this->groupResultsByType($limited); $meta = [ ...$baseMeta, 'returned' => count($limited), 'truncated' => count($initialResults) > count($limited), ]; $payload = [ 'results' => $limited, 'grouped_results' => $grouped, 'meta' => $meta, ]; system_search_cache::setQuery($queryCacheHash, $payload, 120); return $payload; } /** * @param array $activeTypes * @param array $includeTypes * @param array $terms * @return array */ private function selectSearchTypes(array $activeTypes, array $includeTypes, array $terms, string $query): array { if (empty($activeTypes) || !empty($includeTypes)) { return $activeTypes; } $selected = array_values(array_intersect($activeTypes, $this->defaultSearchEntityTypes())); foreach ($activeTypes as $entityType) { if (in_array($entityType, $selected, true)) { continue; } if ($this->entityTypeMatchesQuery($entityType, $terms, $query)) { $selected[] = $entityType; } } return array_values(array_intersect($activeTypes, array_values(array_unique($selected)))); } /** * @return array */ private function defaultSearchEntityTypes(): array { return [ 'customers', 'users', 'employees', 'orders', 'order_bookings', 'bookings', 'bookings_new', 'invoices', 'vehicles', 'departments', 'products', 'objects', ]; } /** * @param array $terms */ private function entityTypeMatchesQuery(string $entityType, array $terms, string $query): bool { $normalizedQuery = ' ' . trim(mb_strtolower($query)) . ' '; if (trim($normalizedQuery) === '') { return false; } $aliases = system_search_registry::taxonomyAliases()[$entityType] ?? []; $human = str_replace('_', ' ', $entityType); $aliases[] = $entityType; $aliases[] = $human; $aliases[] = rtrim($human, 's'); $aliases = array_values(array_unique(array_filter($aliases, static fn($alias) => is_string($alias) && trim($alias) !== ''))); foreach ($aliases as $alias) { $aliasTerms = $this->tokenize($alias); if (empty($aliasTerms)) { continue; } if (count($aliasTerms) === 1 && in_array($aliasTerms[0], $terms, true)) { return true; } if (count($aliasTerms) > 1 && empty(array_diff($aliasTerms, $terms))) { return true; } $aliasText = trim(mb_strtolower($alias)); if ($aliasText !== '' && str_contains($normalizedQuery, ' ' . $aliasText . ' ')) { return true; } } return false; } /** * @param array> $results * @param array $includeTypes * @return array> */ private function filterRelevantResults(array $results, array $includeTypes): array { $explicitTypes = array_flip($includeTypes); $filtered = []; foreach ($results as $result) { $entityType = (string)($result['entity_type'] ?? ''); $score = (int)($result['score'] ?? 0); if ($score <= 0) { continue; } if (isset($explicitTypes[$entityType])) { if ($score >= $this->minimumExplicitTypeScore) { $filtered[] = $result; } continue; } if ($this->effectiveResultScore($result) >= $this->minimumEffectiveScore) { $filtered[] = $result; } } return $filtered; } private function shouldExpandAssociationsFromResult(array $result, array $includeTypes): bool { $entityType = trim(mb_strtolower((string)($result['entity_type'] ?? ''))); if (!in_array($entityType, ['customers', 'users'], true)) { return false; } if (!empty($includeTypes) && !in_array($entityType, $includeTypes, true)) { return false; } return $this->effectiveResultScore($result) >= $this->associationSeedScoreThreshold; } /** * @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 $allowedDepartmentIds * @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 $allowedDepartmentIds = [], array $forcedCustomerNumbers = [] ): array { $results = []; $dirtyTables = system_search_cache::peekDirtyTables(); foreach ($activeTypes as $entityType) { $boost = (int)($entityBoost[$entityType] ?? 0); $ownOnly = in_array($entityType, $ownOnlyTypes, true); if ($ownOnly && $ownCustomerNumber === null && empty($forcedCustomerNumbers)) { continue; } if ($this->canUseIndexedSearch($entityType, $dirtyTables)) { $rows = $this->searchIndexedEntity( $entityType, $terms, $boost, $ownOnly, $ownCustomerNumber, $moduleConfigVisibility, $allowedDepartmentIds, $forcedCustomerNumbers ); } else { $rows = $this->searchEntity( $entityType, $terms, $boost, $ownOnly, $ownCustomerNumber, $permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility, $allowedDepartmentIds, $forcedCustomerNumbers ); } $results = $this->mergeResults($results, $rows); } return $results; } /** * @param array $terms * @param array $permissionsCatalogAll * @param array $permissionsCatalogOwn * @param array $moduleConfigVisibility * @param array $allowedDepartmentIds * @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 $allowedDepartmentIds, array $forcedCustomerNumbers ): array { if ($this->isGenericEntityType($entityType)) { return $this->searchGenericEntity( $entityType, $terms, $entityBoost, $ownOnly, $ownCustomerNumber, $allowedDepartmentIds, $forcedCustomerNumbers ); } 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 => [], }; } /** * @param array $dirtyTables */ private function canUseIndexedSearch(string $entityType, array $dirtyTables): bool { if (!$this->tableExists(system_search_document_index::TABLE)) { return false; } if (!in_array($entityType, system_search_registry::indexedEntityTypes(), true)) { return false; } $normalizedDirty = array_values(array_unique(array_filter(array_map( static fn($table) => is_string($table) ? trim($table, " `\t\n\r\0\x0B") : '', $dirtyTables )))); if (empty($normalizedDirty)) { return true; } return empty(array_intersect($normalizedDirty, system_search_registry::sourceTablesForEntityType($entityType))); } private function indexedEntitySupportsDepartmentFilter(string $entityType): bool { $entityType = trim(mb_strtolower($entityType)); if (in_array($entityType, ['orders', 'objects'], true)) { return true; } $config = system_search_registry::genericEntityConfigs()[$entityType] ?? null; return is_array($config) && isset($config['department_field']) && is_string($config['department_field']) && trim($config['department_field']) !== ''; } /** * @param array $terms * @param array $moduleConfigVisibility * @param array $allowedDepartmentIds * @param array $forcedCustomerNumbers * @return array> */ private function searchIndexedEntity( string $entityType, array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $moduleConfigVisibility, array $allowedDepartmentIds, array $forcedCustomerNumbers ): array { global $db; if (!$this->tableExists(system_search_document_index::TABLE) || empty($terms)) { return []; } $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : (($ownOnly && $ownCustomerNumber !== null) ? [$ownCustomerNumber] : []); if ($ownOnly && empty($customerNumbers)) { return []; } $wheres = [ "`entity_type` = '" . $db->escape_string($entityType) . "'", ]; if (!empty($customerNumbers)) { $wheres[] = "`customer_number` IN (" . implode(',', array_map('intval', $customerNumbers)) . ")"; } if (!empty($allowedDepartmentIds) && $this->indexedEntitySupportsDepartmentFilter($entityType)) { $wheres[] = "`department_id` IN (" . implode(',', array_map('intval', $allowedDepartmentIds)) . ")"; } $booleanQuery = $this->buildBooleanFullTextQuery($terms); $rows = []; if ($booleanQuery !== null) { $escapedBoolean = $db->escape_string($booleanQuery); $rows = $this->runSelectRows( "SELECT entity_id, customer_number, department_id, title, description, search_text, payload_json, created_at, updated_at, " . "MATCH(title, description, search_text) AGAINST ('" . $escapedBoolean . "' IN BOOLEAN MODE) AS indexed_score " . "FROM `" . system_search_document_index::TABLE . "` " . "WHERE " . implode(' AND ', $wheres) . " AND MATCH(title, description, search_text) AGAINST ('" . $escapedBoolean . "' IN BOOLEAN MODE)" . " ORDER BY indexed_score DESC LIMIT " . $this->defaultEntityFetchLimit ); } if (empty($rows)) { $termClauses = []; foreach ($terms as $term) { $escaped = $db->escape_string($term); foreach (['title', 'description', 'search_text'] as $field) { $termClauses[] = "`$field` LIKE '%$escaped%'"; } } if (empty($termClauses)) { return []; } $rows = $this->runSelectRows( "SELECT entity_id, customer_number, department_id, title, description, search_text, payload_json, created_at, updated_at, 0 AS indexed_score " . "FROM `" . system_search_document_index::TABLE . "` " . "WHERE " . implode(' AND ', $wheres) . " AND (" . implode(' OR ', $termClauses) . ")" . " LIMIT " . $this->defaultEntityFetchLimit ); } $this->primeCustomerContexts(array_values(array_unique(array_filter( array_map(fn(array $row): ?int => $this->toIntOrNull($row['customer_number'] ?? null), $rows), static fn(?int $value): bool => $value !== null && $value > 0 )))); $invoiceTitleContexts = $entityType === 'invoices' ? $this->loadInvoiceTitleContexts(array_map(static fn(array $row): mixed => $row['entity_id'] ?? null, $rows)) : []; $results = []; foreach ($rows as $row) { $payload = []; $payloadJson = $row['payload_json'] ?? null; if (is_string($payloadJson) && $payloadJson !== '') { $decoded = json_decode($payloadJson, true); if (is_array($decoded)) { $payload = $decoded; } } $title = (string)($row['title'] ?? ''); if ($entityType === 'invoices') { $invoiceContext = $invoiceTitleContexts[(string)($row['entity_id'] ?? '')] ?? []; foreach (['name', 'created_at', 'closed_at'] as $field) { if (array_key_exists($field, $invoiceContext)) { $payload[$field] = $invoiceContext[$field]; } } $storedTitle = trim($title); $storedName = null; if ($storedTitle !== '' && !str_starts_with($storedTitle, 'Invoice collection #')) { $storedName = $storedTitle; } $title = $this->invoiceResultTitle( $payload['name'] ?? $storedName, $payload['created_at'] ?? ($row['created_at'] ?? null), $payload['closed_at'] ?? null, $row['entity_id'] ?? null ); } if ($entityType === 'department_goals') { $title = $this->departmentGoalResultTitle( $payload['criteria'] ?? null, $row['entity_id'] ?? null, $title ); } if ($entityType === 'module_config') { $module = (string)($payload['module'] ?? ''); if ($module !== '' && isset($moduleConfigVisibility[$module]) && !$moduleConfigVisibility[$module]) { continue; } $variable = (string)($payload['variable'] ?? ''); if ($variable !== '' && $this->looksSecretVariable($variable)) { continue; } } $indexedBoost = (int)round(max(0.0, (float)($row['indexed_score'] ?? 0.0)) * 40); $score = $this->scoreRow([ 'title' => $title, 'description' => $row['description'] ?? '', 'search_text' => $row['search_text'] ?? '', ], ['title' => 4, 'description' => 2, 'search_text' => 1], $terms) + $indexedBoost + $entityBoost; if ($score <= 0) { continue; } $results[] = $this->decorateSearchResultWithCustomerContext([ 'entity_type' => $entityType, 'entity_id' => (string)($row['entity_id'] ?? ''), 'title' => $title, 'description' => (string)($row['description'] ?? ''), 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), 'department_id' => $this->toIntOrNull($row['department_id'] ?? null), 'score' => $score, 'payload' => $payload, ]); } return $results; } /** * @param array $entityIds * @return array */ private function loadInvoiceTitleContexts(array $entityIds): array { if (!$this->tableExists('collected_order_invoices')) { return []; } $invoiceIds = array_values(array_unique(array_filter(array_map( fn(mixed $entityId): ?int => $this->toIntOrNull($entityId), $entityIds ), static fn(?int $invoiceId): bool => $invoiceId !== null && $invoiceId > 0))); if (empty($invoiceIds)) { return []; } $rows = $this->runSelectRows( "SELECT id, name, created_at, closed_at" . " FROM `collected_order_invoices`" . " WHERE `deleted_at` IS NULL" . " AND `id` IN (" . implode(',', $invoiceIds) . ")" ); $contexts = []; foreach ($rows as $row) { if (!isset($row['id'])) { continue; } $contexts[(string)$row['id']] = [ 'name' => array_key_exists('name', $row) ? $row['name'] : null, 'created_at' => array_key_exists('created_at', $row) ? $row['created_at'] : null, 'closed_at' => array_key_exists('closed_at', $row) ? $row['closed_at'] : null, ]; } return $contexts; } private function searchCustomers(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array { $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : (($ownOnly && $ownCustomerNumber) ? [$ownCustomerNumber] : []); $customerFilter = ''; if (!empty($customerNumbers)) { $customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $customerNumbers)) . ')'; } $selectFields = [ 'u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone', ...$this->joinTemporalSelectFields('users', 'u'), ]; $searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone']; $fromClause = 'users u'; if ($this->isEconomicCustomerIndexAvailable()) { $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number'; $selectFields = [ ...$selectFields, 'sci.economic_name', 'sci.economic_address', 'sci.economic_city', 'sci.economic_zip', 'sci.economic_email', 'sci.economic_cvr', 'sci.economic_mobile_phone', 'sci.search_text', ]; $searchFields = [ ...$searchFields, 'sci.economic_name', 'sci.economic_address', 'sci.economic_city', 'sci.economic_zip', 'sci.economic_email', 'sci.economic_cvr', 'sci.economic_mobile_phone', 'sci.search_text', ]; } $rows = $this->searchTableWithJoin( 'users', $fromClause, $selectFields, $searchFields, $terms, '1=1' . $customerFilter ); return array_map(function (array $row) use ($terms, $entityBoost) { $title = trim((string)($row['economic_name'] ?? '')); if ($title === '') { $title = trim((string)($row['display_name'] ?? '')); } if ($title === '') { $title = 'Customer #' . (string)($row['customer_number'] ?? ''); } $description = trim((string)($row['email'] ?? '')); if ($description === '') { $description = trim((string)($row['economic_email'] ?? '')); } return [ 'entity_type' => 'customers', 'entity_id' => (string)$row['id'], 'title' => $title, 'description' => $description, 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'score' => $this->scoreRow($row, [ 'customer_number', 'display_name', 'email', 'phone', 'economic_name', 'economic_address', 'economic_city', 'economic_zip', 'economic_email', 'economic_cvr', 'economic_mobile_phone', 'search_text', ], $terms) + $entityBoost, 'payload' => $this->augmentPayloadWithTemporal([ '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, 'economic_name' => $row['economic_name'] ?? null, 'economic_address' => $row['economic_address'] ?? null, 'economic_city' => $row['economic_city'] ?? null, 'economic_zip' => $row['economic_zip'] ?? null, 'economic_email' => $row['economic_email'] ?? null, 'economic_cvr' => $row['economic_cvr'] ?? null, 'economic_mobile_phone' => $row['economic_mobile_phone'] ?? null, ], $row), ]; }, $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', ...$this->joinTemporalSelectFields('users', 'u')], ['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' => $this->augmentPayloadWithTemporal([ 'id' => (int)$row['id'], 'display_name' => $row['display_name'] ?? null, 'email' => $row['email'] ?? null, 'phone' => $row['phone'] ?? null, ], $row), ]; }, $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', 'default', ['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' => $this->augmentPayloadWithTemporal([ '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, ], $row), ]; }, $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', ...$this->joinTemporalSelectFields('order_items', 'oi'), ], ['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' => $this->augmentPayloadWithTemporal([ '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, ], $row), ]; }, $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', 'closed_at', 'deleted_at'], ['id', 'customer_number', 'name', 'notes', 'external_id', 'booked_invoice_id', 'po_number'], $terms, $customerNumbers, 'customer_number', 'default', ['deleted_at' => null] ); return array_map(function (array $row) use ($terms, $entityBoost) { return [ 'entity_type' => 'invoices', 'entity_id' => (string)$row['id'], 'title' => $this->invoiceResultTitle( $row['name'] ?? null, $row['created_at'] ?? null, $row['closed_at'] ?? null, $row['id'] ?? null ), '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' => $this->augmentPayloadWithTemporal([ '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, 'closed_at' => $row['closed_at'] ?? null, ], $row), ]; }, $rows); } private function invoiceResultTitle(mixed $name, mixed $fromDate, mixed $toDate, mixed $invoiceId): string { $resolvedName = trim((string)($name ?? '')); if ($resolvedName !== '') { return $resolvedName; } $dateRange = $this->invoiceDateRangeLabel($fromDate, $toDate); if ($dateRange !== '') { return $dateRange; } return 'Invoice collection #' . (string)$invoiceId; } private function invoiceDateRangeLabel(mixed $fromDate, mixed $toDate): string { $fromLabel = $this->invoiceDateLabel($fromDate); $toLabel = $this->invoiceDateLabel($toDate); if ($fromLabel !== '' && $toLabel !== '' && $fromLabel !== $toLabel) { return $fromLabel . ' - ' . $toLabel; } if ($fromLabel !== '') { return $fromLabel; } if ($toLabel !== '') { return $toLabel; } return ''; } private function invoiceDateLabel(mixed $value): string { if ($value === null) { return ''; } $raw = trim((string)$value); if ($raw === '' || $raw === '0000-00-00' || $raw === '0000-00-00 00:00:00') { return ''; } $timestamp = strtotime($raw); if ($timestamp === false) { return ''; } return date('Y-m-d', $timestamp); } private function departmentGoalResultTitle(mixed $criteria, mixed $entityId = null, string $fallback = ''): string { $label = $this->departmentGoalLabelFromCriteria($criteria); if ($label !== '') { return $label; } $trimmedFallback = trim($fallback); if ($trimmedFallback !== '') { return $trimmedFallback; } $resolvedId = $this->toIntOrNull($entityId); return $resolvedId !== null && $resolvedId > 0 ? 'Department goal #' . $resolvedId : 'Department goal'; } private function departmentGoalLabelFromCriteria(mixed $criteria): string { $decoded = null; if (is_array($criteria)) { $decoded = $criteria; } elseif (is_string($criteria) && trim($criteria) !== '') { $decodedValue = json_decode($criteria, true); if (is_array($decodedValue)) { $decoded = $decodedValue; } } if (!is_array($decoded)) { return ''; } return trim((string)($decoded['label'] ?? '')); } 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', 'default', ['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' => $this->augmentPayloadWithTemporal([ 'id' => (int)$row['id'], 'customer_id' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, 'reg' => $row['reg'] ?? null, 'reference' => $row['reference'] ?? null, ], $row), ]; }, $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', ...$this->joinTemporalSelectFields('subusers', 's'), ], ['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' => $this->augmentPayloadWithTemporal([ 'id' => (int)$row['id'], 'username' => $row['username'] ?? null, 'name' => $row['name'] ?? null, 'email' => $row['email'] ?? null, ], $row), ]; }, $rows); } private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array { price_overrides_schema_bootstrap::ensureColumns(); $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; } $fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id'; $selectFields = [ 'po.id', 'po.user_id', 'po.is_category', 'po.product_or_category_id', 'po.percentage', 'po.fixed_price', 'u.customer_number', 'u.display_name', ...$this->joinTemporalSelectFields('price_overrides', 'po'), ]; $searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'po.fixed_price', 'u.customer_number', 'u.display_name']; if ($this->isEconomicCustomerIndexAvailable()) { $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number'; $selectFields = [ ...$selectFields, 'sci.economic_name', 'sci.economic_address', 'sci.economic_city', 'sci.economic_zip', 'sci.economic_email', 'sci.economic_cvr', 'sci.economic_mobile_phone', 'sci.search_text', ]; $searchFields = [ ...$searchFields, 'sci.economic_name', 'sci.economic_address', 'sci.economic_city', 'sci.economic_zip', 'sci.economic_email', 'sci.economic_cvr', 'sci.economic_mobile_phone', 'sci.search_text', ]; } $rows = $this->searchTableWithJoin( 'price_overrides', $fromClause, $selectFields, $searchFields, $terms, '1=1' . $customerFilter ); return array_map(function (array $row) use ($terms, $entityBoost) { $customerDisplay = trim((string)($row['economic_name'] ?? '')); if ($customerDisplay === '') { $customerDisplay = trim((string)($row['display_name'] ?? '')); } return [ 'entity_type' => 'customer_discounts', 'entity_id' => (string)$row['id'], 'title' => 'Discount #' . (string)$row['id'], 'description' => (string)('Customer ' . ($row['customer_number'] ?? '') . ' / ' . $customerDisplay), 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'score' => $this->scoreRow($row, [ 'id', 'customer_number', 'display_name', 'economic_name', 'economic_address', 'economic_city', 'economic_zip', 'economic_email', 'economic_cvr', 'economic_mobile_phone', 'search_text', 'product_or_category_id', 'percentage', 'fixed_price', 'user_id', ], $terms) + $entityBoost, 'payload' => $this->augmentPayloadWithTemporal([ '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, 'fixed_price' => isset($row['fixed_price']) ? (int)$row['fixed_price'] : null, 'economic_name' => $row['economic_name'] ?? null, 'economic_cvr' => $row['economic_cvr'] ?? null, ], $row), ]; }, $rows); } private function searchCustomerFixedPrices(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array { $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : (($ownOnly && $ownCustomerNumber) ? [$ownCustomerNumber] : []); $customerFilter = ''; if (!empty($customerNumbers)) { $customerFilter = ' AND cfp.customer_number IN (' . implode(',', array_map('intval', $customerNumbers)) . ')'; } $fromClause = 'customer_fixed_pricing cfp'; $selectFields = [ 'cfp.id', 'cfp.customer_number', 'cfp.price', 'cfp.description', ...$this->joinTemporalSelectFields('customer_fixed_pricing', 'cfp'), ]; $searchFields = ['cfp.id', 'cfp.customer_number', 'cfp.price', 'cfp.description']; if ($this->isEconomicCustomerIndexAvailable()) { $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = cfp.customer_number'; $selectFields = [ ...$selectFields, 'sci.economic_name', 'sci.economic_address', 'sci.economic_city', 'sci.economic_zip', 'sci.economic_email', 'sci.economic_cvr', 'sci.economic_mobile_phone', 'sci.search_text', ]; $searchFields = [ ...$searchFields, 'sci.economic_name', 'sci.economic_address', 'sci.economic_city', 'sci.economic_zip', 'sci.economic_email', 'sci.economic_cvr', 'sci.economic_mobile_phone', 'sci.search_text', ]; } $rows = $this->searchTableWithJoin( 'customer_fixed_pricing', $fromClause, $selectFields, $searchFields, $terms, '1=1' . $customerFilter ); return array_map(function (array $row) use ($terms, $entityBoost) { $description = trim((string)($row['description'] ?? '')); if ($description === '') { $description = trim((string)($row['economic_name'] ?? '')); } return [ 'entity_type' => 'customer_fixed_prices', 'entity_id' => (string)$row['id'], 'title' => 'Fixed pricing #' . (string)$row['id'], 'description' => $description, 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'score' => $this->scoreRow($row, [ 'id', 'customer_number', 'price', 'description', 'economic_name', 'economic_address', 'economic_city', 'economic_zip', 'economic_email', 'economic_cvr', 'economic_mobile_phone', 'search_text', ], $terms) + $entityBoost, 'payload' => $this->augmentPayloadWithTemporal([ '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, 'economic_name' => $row['economic_name'] ?? null, 'economic_cvr' => $row['economic_cvr'] ?? null, ], $row), ]; }, $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' => $this->augmentPayloadWithTemporal([ 'id' => (int)$row['id'], 'name' => $row['name'] ?? null, 'address' => $row['address'] ?? null, 'zip' => $row['zip'] ?? null, 'city' => $row['city'] ?? null, ], $row), ]; }, $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' => $this->augmentPayloadWithTemporal([ 'id' => (int)$row['id'], 'name' => $row['name'] ?? null, 'description' => $row['description'] ?? null, ], $row), ]; }, $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' => $this->augmentPayloadWithTemporal([ 'module' => $module, 'variable' => $variable, 'type' => $row['type'] ?? null, ], $row), ]; } return $filtered; } private function searchObjects(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array { global $db; if ($ownOnly && $ownCustomerNumber === null) { return []; } $taskColumns = $this->getColumns('department_selfserve_tasks'); $taskSelectFields = []; $taskSearchFields = []; if (in_array('task', $taskColumns, true)) { $taskSelectFields[] = 'dst.task AS task_title'; $taskSearchFields[] = 'dst.task'; } if (in_array('description', $taskColumns, true)) { $taskSelectFields[] = 'dst.description AS task_description'; $taskSearchFields[] = 'dst.description'; } if (in_array('department', $taskColumns, true)) { $taskSelectFields[] = 'dst.department AS task_department'; } $taskDepartmentJoin = ''; if ($this->tableExists('departments') && in_array('department', $taskColumns, true) && in_array('name', $this->getColumns('departments'), true)) { $taskDepartmentJoin = ' LEFT JOIN departments d ON d.id = dst.department'; $taskSelectFields[] = 'd.name AS task_department_name'; $taskSearchFields[] = 'd.name'; } $customerJoin = ''; $customerSelectFields = []; $customerSearchFields = ['o.customer_id', 'o.reference']; if ($this->tableExists(system_search_economic_customer_index::TABLE)) { $customerJoin = " LEFT JOIN `" . system_search_economic_customer_index::TABLE . "` sci ON sci.customer_number = o.customer_id"; $customerSelectFields = [ "COALESCE(sci.economic_name, sci.local_display_name) AS customer_name", "COALESCE(sci.economic_email, sci.local_email) AS customer_email", "COALESCE(sci.economic_mobile_phone, sci.local_phone) AS customer_phone", "sci.economic_cvr AS customer_cvr", "sci.economic_barred AS customer_barred", ]; $customerSearchFields = [ ...$customerSearchFields, 'sci.economic_name', 'sci.local_display_name', 'sci.economic_email', 'sci.local_email', 'sci.economic_mobile_phone', 'sci.local_phone', 'sci.economic_cvr', ]; } $termClauses = []; foreach ($terms as $term) { $escaped = $db->escape_string($term); foreach (array_values(array_unique([ 'oa.id', 'oa.object_type', 'oa.object_id', 'oa.content', ...$customerSearchFields, ...$taskSearchFields, ])) as $field) { $termClauses[] = $field . " LIKE '%$escaped%'"; } } if (empty($termClauses)) { return []; } $scopeClauses = []; if ($ownOnly) { $scopeClauses[] = "(oa.object_type = 'orders' AND o.customer_id = " . (int)$ownCustomerNumber . ")"; } else { $scopeClauses[] = "(oa.object_type = 'orders' AND o.id IS NOT NULL)"; $scopeClauses[] = "(oa.object_type = 'department_selfserve_tasks' AND dst.id IS NOT NULL)"; } $rows = $this->runSelectRows( "SELECT oa.id, oa.object_type, oa.object_id, oa.content, oa.created_at, oa.updated_at," . " o.customer_id AS customer_number, o.department_id, o.reference AS order_reference," . (!empty($taskSelectFields) ? (' ' . ', ' . implode(', ', array_values(array_unique($taskSelectFields)))) : '') . (!empty($customerSelectFields) ? (' ' . ', ' . implode(', ', $customerSelectFields)) : '') . " FROM object_attachments oa" . " LEFT JOIN orders o ON oa.object_type = 'orders' AND o.id = oa.object_id AND o.deleted_at IS NULL" . " LEFT JOIN department_selfserve_tasks dst ON oa.object_type = 'department_selfserve_tasks' AND dst.id = oa.object_id AND dst.deleted_at IS NULL" . $taskDepartmentJoin . $customerJoin . " WHERE oa.deleted_at IS NULL" . " AND (" . implode(' OR ', $scopeClauses) . ")" . " AND (" . implode(' OR ', $termClauses) . ")" . " LIMIT " . $this->defaultEntityFetchLimit ); $this->primeCustomerContexts(array_values(array_unique(array_filter( array_map(fn(array $row): ?int => $this->toIntOrNull($row['customer_number'] ?? null), $rows), static fn(?int $value): bool => $value !== null && $value > 0 )))); return array_map( fn(array $row): array => $this->buildObjectSearchResult($row, $terms, $entityBoost), $rows ); } private function isGenericEntityType(string $entityType): bool { $configs = $this->genericEntityConfigs(); return isset($configs[$entityType]); } /** * @return array */ private function associationEntityTypes(): array { $types = ['orders', 'order_items', 'invoices', 'vehicles', 'customer_discounts', 'customer_fixed_prices']; foreach ($this->genericEntityConfigs() as $entityType => $config) { if (isset($config['customer_field']) && is_string($config['customer_field']) && $config['customer_field'] !== '') { $types[] = $entityType; } } return array_values(array_unique($types)); } /** * @param array $terms * @param array $allowedDepartmentIds * @param array $forcedCustomerNumbers * @return array> */ private function searchGenericEntity( string $entityType, array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $allowedDepartmentIds = [], array $forcedCustomerNumbers = [] ): array { if (empty($terms)) { return []; } $config = $this->genericEntityConfigs()[$entityType] ?? null; if (!is_array($config)) { return []; } $table = trim((string)($config['table'] ?? '')); if ($table === '' || !$this->tableExists($table)) { return []; } $columns = $this->getColumns($table); if (empty($columns)) { return []; } $idField = (string)($config['id_field'] ?? (in_array('id', $columns, true) ? 'id' : $columns[0])); if (!in_array($idField, $columns, true)) { return []; } $customerField = null; if (isset($config['customer_field']) && is_string($config['customer_field']) && in_array($config['customer_field'], $columns, true)) { $customerField = $config['customer_field']; } $customerFieldMode = isset($config['customer_field_mode']) && is_string($config['customer_field_mode']) ? trim(mb_strtolower($config['customer_field_mode'])) : 'default'; if ($customerFieldMode === '') { $customerFieldMode = 'default'; } if ($ownOnly) { if ($customerField === null) { return []; } if (empty($forcedCustomerNumbers) && $ownCustomerNumber === null) { return []; } } $departmentField = null; if (isset($config['department_field']) && is_string($config['department_field']) && in_array($config['department_field'], $columns, true)) { $departmentField = $config['department_field']; } $excludedColumns = []; if (isset($config['exclude_columns']) && is_array($config['exclude_columns'])) { $excludedColumns = array_values(array_filter($config['exclude_columns'], static fn($v) => is_string($v) && $v !== '')); } $searchable = []; if (isset($config['search_fields']) && is_array($config['search_fields']) && !empty($config['search_fields'])) { $configured = array_values(array_filter($config['search_fields'], static fn($v) => is_string($v) && $v !== '')); $configured = array_values(array_intersect($configured, $columns)); $searchable = $this->sanitizeGenericSearchFields($configured, $excludedColumns); } if (empty($searchable)) { $searchable = $this->sanitizeGenericSearchFields($columns, $excludedColumns); } if (empty($searchable)) { return []; } $selectFields = array_values(array_unique(array_filter([ $idField, $customerField, $departmentField, ...$searchable, ], static fn($value) => is_string($value) && $value !== ''))); if (count($selectFields) > 24) { $selectFields = array_slice($selectFields, 0, 24); } $searchable = array_values(array_intersect($searchable, $selectFields)); $fixedConditions = []; if (isset($config['fixed_conditions']) && is_array($config['fixed_conditions'])) { foreach ($config['fixed_conditions'] as $column => $value) { if (!is_string($column) || !in_array($column, $columns, true)) { continue; } $fixedConditions[$column] = $value; } } if (!array_key_exists('deleted_at', $fixedConditions) && in_array('deleted_at', $columns, true)) { $fixedConditions['deleted_at'] = null; } $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : (($ownOnly && $ownCustomerNumber !== null && $customerField !== null) ? [$ownCustomerNumber] : []); $rows = $this->searchTable( $table, $selectFields, $searchable, $terms, $customerNumbers, $customerField, $customerFieldMode, $fixedConditions, $allowedDepartmentIds, $departmentField ); $this->primeCustomerContexts(array_values(array_unique(array_filter( array_map( fn(array $row): ?int => ($customerField !== null && array_key_exists($customerField, $row)) ? $this->resolveConfiguredCustomerNumber($row[$customerField], $customerFieldMode) : null, $rows ), static fn(?int $value): bool => $value !== null && $value > 0 )))); $titleFields = []; if (isset($config['title_fields']) && is_array($config['title_fields'])) { $titleFields = array_values(array_filter($config['title_fields'], static fn($v) => is_string($v) && in_array($v, $selectFields, true))); } if (empty($titleFields)) { $titleFields = array_values(array_intersect( ['name', 'title', 'display_name', 'reference', 'reference_number', 'reg', 'reg_1', 'plate', 'module', 'customer_number', 'id'], $selectFields )); } $descriptionFields = []; if (isset($config['description_fields']) && is_array($config['description_fields'])) { $descriptionFields = array_values(array_filter($config['description_fields'], static fn($v) => is_string($v) && in_array($v, $selectFields, true))); } if (empty($descriptionFields)) { $descriptionFields = array_values(array_intersect( ['description', 'note', 'notes', 'email', 'status', 'type', 'city', 'address', 'action', 'message', 'customer_id'], $selectFields )); } $entityLabel = ucfirst(str_replace('_', ' ', $entityType)); $results = []; foreach ($rows as $row) { $entityId = isset($row[$idField]) ? (string)$row[$idField] : md5(json_encode($row, JSON_UNESCAPED_UNICODE)); $title = ''; foreach ($titleFields as $field) { $value = trim((string)($row[$field] ?? '')); if ($value !== '') { $title = $value; break; } } if ($entityType === 'department_goals') { $title = $this->departmentGoalResultTitle($row['criteria'] ?? null, $entityId, $title); } if ($title === '') { $title = $entityLabel . ' #' . $entityId; } $descriptionParts = []; foreach ($descriptionFields as $field) { $value = trim((string)($row[$field] ?? '')); if ($value === '') { continue; } $descriptionParts[] = $value; if (count($descriptionParts) >= 2) { break; } } $description = implode(' / ', $descriptionParts); $results[] = $this->decorateSearchResultWithCustomerContext([ 'entity_type' => $entityType, 'entity_id' => $entityId, 'title' => $title, 'description' => $description, 'customer_number' => ($customerField !== null && array_key_exists($customerField, $row)) ? $this->resolveConfiguredCustomerNumber($row[$customerField], $customerFieldMode) : null, 'department_id' => ($departmentField !== null && isset($row[$departmentField])) ? $this->toIntOrNull($row[$departmentField]) : null, 'score' => $this->scoreRow($row, $searchable, $terms) + $entityBoost, 'payload' => $this->augmentPayloadWithTemporal( array_intersect_key($row, array_flip([...$selectFields, 'updated_at', 'created_at'])), $row ), ]); } return $results; } /** * @param array $columns * @param array $excludeColumns * @return array */ private function sanitizeGenericSearchFields(array $columns, array $excludeColumns = []): array { $excluded = array_values(array_unique(array_map(static fn($v) => mb_strtolower((string)$v), $excludeColumns))); $filtered = []; foreach ($columns as $column) { if (!is_string($column) || $column === '') { continue; } $lower = mb_strtolower($column); if (in_array($lower, $excluded, true)) { continue; } if (in_array($lower, ['created_at', 'updated_at'], true)) { continue; } if (preg_match('/(?:^|_)(password|token|secret|api_key|apikey|private|credential|passkey|session|hash|salt|client_secret|refresh_token|access_token)(?:_|$)/i', $lower)) { continue; } if (in_array($lower, ['data', 'content', 'payload', 'config', 'permissions', 'washitems', 'client_secret'], true)) { continue; } $filtered[] = $column; } return array_values(array_unique($filtered)); } /** * @return array> */ private function genericEntityConfigs(): array { return system_search_registry::genericEntityConfigs(); } private function toIntOrNull(mixed $value): ?int { if (is_int($value)) { return $value; } if (is_string($value) && preg_match('/^-?\d+$/', $value)) { return (int)$value; } if (is_float($value)) { return (int)$value; } return null; } private function resolveConfiguredCustomerNumber(mixed $value, string $mode = 'default'): ?int { if ($mode !== 'digits_only') { return $this->toIntOrNull($value); } if (is_int($value)) { return $value > 0 ? $value : null; } if (!is_string($value)) { return null; } $trimmed = trim($value); if ($trimmed === '' || !preg_match('/^\d+$/', $trimmed)) { return null; } $resolved = (int)$trimmed; return $resolved > 0 ? $resolved : null; } /** * @param array $customerNumbers */ private function customerFieldFilterClause(string $customerField, array $customerNumbers, string $customerFieldMode = 'default'): string { $normalizedNumbers = array_values(array_unique(array_filter( array_map('intval', $customerNumbers), static fn(int $value): bool => $value > 0 ))); if (empty($normalizedNumbers)) { return ''; } if ($customerFieldMode === 'digits_only') { return "TRIM(`$customerField`) REGEXP '^[0-9]+$' AND CAST(TRIM(`$customerField`) AS UNSIGNED) IN (" . implode(',', $normalizedNumbers) . ")"; } return "`$customerField` IN (" . implode(',', $normalizedNumbers) . ")"; } /** * Generic table search helper. * * @param array $candidateFields * @param array $searchFields * @param array $terms * @param array $customerNumbers * @param string|null $customerField * @param string $customerFieldMode * @param array $fixedConditions * @param array $departmentIds * @param string|null $departmentField * @return array> */ private function searchTable( string $table, array $candidateFields, array $searchFields, array $terms, array $customerNumbers = [], ?string $customerField = null, string $customerFieldMode = 'default', array $fixedConditions = [], array $departmentIds = [], ?string $departmentField = null ): array { global $db; if (!$this->tableExists($table)) { return []; } $fields = $this->intersectExistingColumns($table, $candidateFields); if (empty($fields)) { return []; } $fields = $this->appendTemporalColumns($table, $fields); $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)) { $customerFilterClause = $this->customerFieldFilterClause($customerField, $customerNumbers, $customerFieldMode); if ($customerFilterClause !== '') { $wheres[] = $customerFilterClause; } } if (!empty($departmentIds) && $departmentField !== null && in_array($departmentField, $fields, true)) { $wheres[] = "`$departmentField` IN (" . implode(',', array_map('intval', $departmentIds)) . ")"; } $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; return $this->runSelectRows($sql); } /** * 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; return $this->runSelectRows($sql); } /** * @return array> */ protected function runSelectRows(string $sql): array { global $db; try { $result = $db->query($sql); if (!($result instanceof \mysqli_result)) { return []; } return $db->fetch_all($result); } catch (Throwable) { return []; } } private function scoreRow(array $row, array $fields, array $terms): int { $fieldWeights = []; foreach ($fields as $key => $value) { if (is_string($key)) { $fieldWeights[$key] = max(1, (int)$value); continue; } if (is_string($value)) { $fieldWeights[$value] = 1; } } if (empty($fieldWeights) || empty($terms)) { return 0; } $contentTerms = $this->contentTerms($terms); $matchedTerms = []; $score = 0; foreach ($terms as $term) { $termLower = mb_strtolower($term); $bestScore = 0; foreach ($fieldWeights as $field => $weight) { if (!array_key_exists($field, $row) || $row[$field] === null) { continue; } $value = trim((string)$row[$field]); if ($value === '') { continue; } $valueLower = mb_strtolower($value); $baseScore = 0; if ($valueLower === $termLower) { $baseScore = 100; } elseif (str_starts_with($valueLower, $termLower)) { $baseScore = 60; } elseif (str_contains($valueLower, $termLower)) { $baseScore = 30; } elseif (strlen($termLower) >= 4 && strlen($valueLower) <= 64) { $distance = levenshtein($termLower, $valueLower); if ($distance <= 2) { $baseScore = 20 - ($distance * 5); } } if ($baseScore <= 0) { continue; } $bestScore = max($bestScore, $baseScore * $weight); } if ($bestScore > 0) { $score += $bestScore; if (in_array($termLower, $contentTerms, true)) { $matchedTerms[$termLower] = true; } } } if (count($contentTerms) > 1) { $requiredMatches = $this->minimumTermMatches($contentTerms); if (count($matchedTerms) < $requiredMatches) { return 0; } } return $score; } /** * @param array $terms * @return array */ private function contentTerms(array $terms): array { $stopwords = [ 'a', 'an', 'and', 'at', 'between', 'find', 'for', 'fra', 'from', 'har', 'have', 'hvilke', 'hvor', 'i', 'med', 'need', 'of', 'og', 'or', 'search', 'show', 'som', 'the', 'til', 'uden', 'want', 'where', 'which', 'with', 'without', 'booking', 'bookings', 'customer', 'customers', 'discount', 'discounts', 'faktura', 'invoice', 'invoices', 'kunde', 'kunder', 'order', 'orders', 'rabat', 'user', 'users', 'vehicle', 'vehicles', ]; $filtered = []; foreach ($terms as $term) { $normalized = trim(mb_strtolower((string)$term)); if ($normalized === '' || in_array($normalized, $stopwords, true)) { continue; } $filtered[] = $normalized; } $filtered = array_values(array_unique($filtered)); if (!empty($filtered)) { return $filtered; } return array_values(array_unique(array_map(static fn($term) => mb_strtolower((string)$term), $terms))); } /** * @param array $terms */ private function minimumTermMatches(array $terms): int { $count = count($terms); if ($count <= 1) { return 1; } if ($count === 2) { return 2; } return min(3, max(2, (int)ceil($count / 2))); } 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); } /** * @param array $fields * @return array */ private function appendTemporalColumns(string $table, array $fields): array { $columns = $this->getColumns($table); foreach (['updated_at', 'created_at'] as $column) { if (in_array($column, $columns, true) && !in_array($column, $fields, true)) { $fields[] = $column; } } return array_values(array_unique($fields)); } /** * @return array */ private function joinTemporalSelectFields(string $table, string $alias): array { $fields = []; $columns = $this->getColumns($table); $aliasPrefix = trim($alias) === '' ? '' : (trim($alias) . '.'); foreach (['updated_at', 'created_at'] as $column) { if (!in_array($column, $columns, true)) { continue; } $fields[] = $aliasPrefix . $column . ' AS ' . $column; } return $fields; } /** * @param array $payload * @param array $row * @return array */ private function augmentPayloadWithTemporal(array $payload, array $row): array { foreach (['updated_at', 'created_at'] as $column) { if (array_key_exists($column, $row)) { $payload[$column] = $row[$column]; } } return $payload; } /** * @param array $row * @param array $terms * @return array */ private function buildObjectSearchResult(array $row, array $terms, int $entityBoost): array { $context = $this->resolveObjectSearchContext($row); $result = [ 'entity_type' => 'objects', 'entity_id' => (string)($row['id'] ?? ''), 'title' => (string)$context['title'], 'description' => (string)$context['description'], 'customer_number' => $context['customer_number'], 'department_id' => $context['department_id'], 'score' => $this->scoreRow($row, [ 'id' => 3, 'object_type' => 2, 'object_id' => 2, 'content' => 1, 'customer_number' => 3, 'customer_name' => 4, 'customer_email' => 2, 'customer_cvr' => 2, 'order_reference' => 3, 'task_title' => 3, 'task_description' => 2, 'task_department_name' => 2, ], $terms) + $entityBoost, 'payload' => $this->augmentPayloadWithTemporal((array)$context['payload'], $row), ]; return $this->decorateSearchResultWithCustomerContext($result); } /** * @param array $row * @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array} */ private function resolveObjectSearchContext(array $row): array { return match (trim(mb_strtolower((string)($row['object_type'] ?? '')))) { 'orders' => $this->resolveOrderObjectSearchContext($row), 'department_selfserve_tasks' => $this->resolveTaskObjectSearchContext($row), default => $this->resolveGenericObjectSearchContext($row), }; } /** * @param array $row * @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array} */ private function resolveOrderObjectSearchContext(array $row): array { $attachmentName = $this->attachmentNameFromContent($row['content'] ?? null); $title = $attachmentName !== '' ? $attachmentName : ('Order attachment #' . (string)($row['object_id'] ?? '')); $customerNumber = $this->toIntOrNull($row['customer_number'] ?? null); $departmentId = $this->toIntOrNull($row['department_id'] ?? null); $customerName = trim((string)($row['customer_name'] ?? '')); $descriptionParts = []; $orderReference = trim((string)($row['order_reference'] ?? '')); if ($orderReference !== '') { $descriptionParts[] = $orderReference; } if ($customerName !== '') { $descriptionParts[] = $customerName; } return [ 'title' => $title, 'description' => implode(' / ', array_slice($descriptionParts, 0, 2)), 'customer_number' => $customerNumber, 'department_id' => $departmentId, 'payload' => [ 'id' => $this->toIntOrNull($row['id'] ?? null), 'object_type' => $row['object_type'] ?? null, 'object_id' => $this->toIntOrNull($row['object_id'] ?? null), 'linked_entity_type' => 'orders', 'linked_entity_id' => $this->toIntOrNull($row['object_id'] ?? null), 'attachment_name' => $attachmentName !== '' ? $attachmentName : null, 'customer_number' => $customerNumber, 'department_id' => $departmentId, 'order_reference' => $row['order_reference'] ?? null, ], ]; } /** * @param array $row * @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array} */ private function resolveTaskObjectSearchContext(array $row): array { $attachmentName = $this->attachmentNameFromContent($row['content'] ?? null); $title = $attachmentName !== '' ? $attachmentName : ('Task attachment #' . (string)($row['object_id'] ?? '')); $departmentId = $this->toIntOrNull($row['task_department'] ?? null); $descriptionParts = []; foreach (['task_title', 'task_department_name', 'task_description'] as $field) { $value = trim((string)($row[$field] ?? '')); if ($value === '') { continue; } $descriptionParts[] = $value; if (count($descriptionParts) >= 2) { break; } } return [ 'title' => $title, 'description' => implode(' / ', $descriptionParts), 'customer_number' => null, 'department_id' => $departmentId, 'payload' => [ 'id' => $this->toIntOrNull($row['id'] ?? null), 'object_type' => $row['object_type'] ?? null, 'object_id' => $this->toIntOrNull($row['object_id'] ?? null), 'linked_entity_type' => 'department_selfserve_tasks', 'linked_entity_id' => $this->toIntOrNull($row['object_id'] ?? null), 'attachment_name' => $attachmentName !== '' ? $attachmentName : null, 'department_id' => $departmentId, 'task_title' => $row['task_title'] ?? null, 'task_description' => $row['task_description'] ?? null, 'task_department' => $departmentId, 'task_department_name' => $row['task_department_name'] ?? null, ], ]; } /** * @param array $row * @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array} */ private function resolveGenericObjectSearchContext(array $row): array { $attachmentName = $this->attachmentNameFromContent($row['content'] ?? null); $title = $attachmentName !== '' ? $attachmentName : ((string)($row['object_type'] ?? 'object_attachment') . '#' . (string)($row['object_id'] ?? '')); return [ 'title' => $title, 'description' => '', 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), 'department_id' => $this->toIntOrNull($row['department_id'] ?? ($row['task_department'] ?? null)), 'payload' => [ 'id' => $this->toIntOrNull($row['id'] ?? null), 'object_type' => $row['object_type'] ?? null, 'object_id' => $this->toIntOrNull($row['object_id'] ?? null), 'linked_entity_type' => $row['object_type'] ?? null, 'linked_entity_id' => $this->toIntOrNull($row['object_id'] ?? null), 'attachment_name' => $attachmentName !== '' ? $attachmentName : null, 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), 'department_id' => $this->toIntOrNull($row['department_id'] ?? ($row['task_department'] ?? null)), ], ]; } private function attachmentNameFromContent(mixed $content): string { $decodedContent = is_string($content) ? json_decode($content, true) : null; if (!is_array($decodedContent)) { return ''; } return trim((string)($decodedContent['other'] ?? '')); } /** * @param array $customerNumbers */ private function primeCustomerContexts(array $customerNumbers): void { $missing = []; foreach ($customerNumbers as $customerNumber) { $normalized = (int)$customerNumber; if ($normalized <= 0 || array_key_exists($normalized, $this->customerContextCache)) { continue; } $missing[] = $normalized; } if (empty($missing)) { return; } $loaded = $this->loadCustomerContexts($missing); foreach ($missing as $customerNumber) { $this->customerContextCache[$customerNumber] = $loaded[$customerNumber] ?? null; } } /** * @param array $customerNumbers * @return array> */ protected function loadCustomerContexts(array $customerNumbers): array { $contexts = system_search_economic_customer_index::fetchContexts($customerNumbers); foreach ($customerNumbers as $customerNumber) { $normalized = (int)$customerNumber; if ($normalized <= 0 || isset($contexts[$normalized])) { continue; } $fallback = $this->loadFallbackCustomerContext($normalized); if ($fallback !== null) { $contexts[$normalized] = $fallback; } } return $contexts; } /** * @return array|null */ private function loadFallbackCustomerContext(int $customerNumber): ?array { if ($customerNumber <= 0) { return null; } try { $users = new \objects\users_o(); $name = $users->getCustomerName($customerNumber); $userId = null; try { $resolvedUserId = $users->getUserIdFromEconomic($customerNumber); $userId = $resolvedUserId > 0 ? $resolvedUserId : null; } catch (Throwable) { $userId = null; } $resolved = ($name !== null && trim($name) !== '') || $userId !== null; $barred = $resolved ? $users->isCustomerBarred($customerNumber) : null; return [ 'customer_number' => $customerNumber, 'user_id' => $userId, 'name' => is_string($name) && trim($name) !== '' ? trim($name) : null, 'barred' => $barred, 'status' => $this->customerBarredStatus($barred), 'email' => null, 'phone' => null, 'cvr' => null, 'address' => null, 'city' => null, 'zip' => null, ]; } catch (Throwable) { return null; } } /** * @return array|null */ private function customerContext(?int $customerNumber): ?array { if ($customerNumber === null || $customerNumber <= 0) { return null; } $this->primeCustomerContexts([$customerNumber]); return $this->customerContextCache[$customerNumber] ?? null; } /** * @param array $result * @return array */ private function decorateSearchResultWithCustomerContext(array $result): array { $payload = is_array($result['payload'] ?? null) ? $result['payload'] : []; $customerNumber = $this->toIntOrNull($result['customer_number'] ?? ($payload['customer_number'] ?? null)); if ($customerNumber !== null) { $result['customer_number'] = $customerNumber; } $context = $this->customerContext($customerNumber); $result['payload'] = $this->enrichPayloadWithCustomerContext($payload, $customerNumber, $context); if ($context !== null) { $result['customer_name'] = $context['name'] ?? null; $result['customer_barred'] = $context['barred'] ?? null; $result['customer_status'] = $context['status'] ?? $this->customerBarredStatus($context['barred'] ?? null); $result['title'] = $this->overrideUnnamedUserTitleWithCustomerName($result, $context); } return $result; } /** * @param array $result * @param array $context */ private function overrideUnnamedUserTitleWithCustomerName(array $result, array $context): string { $title = trim((string)($result['title'] ?? '')); if (trim(mb_strtolower((string)($result['entity_type'] ?? ''))) !== 'users') { return $title; } if (trim(mb_strtolower($title)) !== 'unnamed') { return $title; } $customerName = trim((string)($context['name'] ?? '')); return $customerName !== '' ? $customerName : $title; } /** * @param array $payload * @param array|null $context * @return array */ private function enrichPayloadWithCustomerContext(array $payload, ?int $customerNumber, ?array $context = null): array { if ($customerNumber !== null) { $payload['customer_number'] = $customerNumber; } if ($context === null) { $context = $this->customerContext($customerNumber); } if ($context === null) { return $payload; } $payload['customer_context'] = $context; $payload['customer_name'] = $context['name'] ?? null; $payload['customer_barred'] = $context['barred'] ?? null; $payload['customer_status'] = $context['status'] ?? $this->customerBarredStatus($context['barred'] ?? null); foreach (['email', 'phone', 'cvr', 'address', 'city', 'zip', 'user_id'] as $key) { if (array_key_exists($key, $context)) { $payload['customer_' . $key] = $context[$key]; } } return $payload; } private function customerBarredStatus(?bool $barred): string { return match ($barred) { true => 'barred', false => 'active', default => 'unknown', }; } private function resultRecencyTimestamp(array $result): int { $timestamps = []; foreach (['updated_at', 'created_at', 'date', 'timestamp'] as $key) { if (array_key_exists($key, $result)) { $timestamps[] = $this->normalizeTimestamp($result[$key]); } } $payload = $result['payload'] ?? null; if (is_array($payload)) { foreach (['updated_at', 'created_at', 'date', 'timestamp'] as $key) { if (!array_key_exists($key, $payload)) { continue; } $timestamps[] = $this->normalizeTimestamp($payload[$key]); } } $timestamps = array_values(array_filter($timestamps, static fn($v) => is_int($v) && $v > 0)); if (empty($timestamps)) { return 0; } return max($timestamps); } private function isCancelledBookingResult(array $result): bool { $entityType = trim(mb_strtolower((string)($result['entity_type'] ?? ''))); if (!in_array($entityType, ['bookings', 'bookings_new', 'order_bookings'], true)) { return false; } $sources = [$result]; $payload = $result['payload'] ?? null; if (is_array($payload)) { $sources[] = $payload; } foreach ($sources as $source) { foreach (['is_cancelled', 'is_canceled', 'cancelled', 'canceled'] as $flag) { if (!array_key_exists($flag, $source)) { continue; } if ($this->boolishTrue($source[$flag])) { return true; } } foreach (['cancelled_at', 'canceled_at', 'deleted_at'] as $timestampField) { if (!array_key_exists($timestampField, $source)) { continue; } if ($this->normalizeTimestamp($source[$timestampField]) > 0) { return true; } } foreach (['status', 'booking_status', 'state'] as $stateField) { if (!array_key_exists($stateField, $source)) { continue; } $state = trim(mb_strtolower((string)$source[$stateField])); if ($state === '') { continue; } if (preg_match('/\b(cancelled?|canceled|aflyst|annulleret|void|voided|cancel)\b/u', $state)) { return true; } } } return false; } private function rankingBoost(array $result): int { $entityType = trim(mb_strtolower((string)($result['entity_type'] ?? ''))); if ($entityType === '') { return 0; } return (int)($this->rankingBoostByType[$entityType] ?? 0); } private function rankingPenalty(array $result): int { $entityType = trim(mb_strtolower((string)($result['entity_type'] ?? ''))); if ($entityType === '') { return 0; } return (int)($this->rankingPenaltyByType[$entityType] ?? 0); } private function effectiveResultScore(array $result): int { return (int)($result['score'] ?? 0) + $this->rankingBoost($result) - $this->rankingPenalty($result); } private function boolishTrue(mixed $value): bool { if (is_bool($value)) { return $value; } if (is_int($value) || is_float($value)) { return (float)$value > 0; } if (!is_string($value)) { return false; } $normalized = trim(mb_strtolower($value)); if ($normalized === '') { return false; } return in_array($normalized, ['1', 'true', 'yes', 'y', 'on'], true); } private function normalizeTimestamp(mixed $value): int { if ($value === null) { return 0; } if (is_int($value)) { if ($value > 2000000000) { return (int)floor($value / 1000); } return max(0, $value); } if (is_float($value)) { return $this->normalizeTimestamp((int)$value); } if (is_string($value)) { $trimmed = trim($value); if ($trimmed === '') { return 0; } if (preg_match('/^\d+$/', $trimmed)) { return $this->normalizeTimestamp((int)$trimmed); } $parsed = strtotime($trimmed); return $parsed !== false ? max(0, (int)$parsed) : 0; } return 0; } /** * @param array $terms */ private function shouldPreferRecencySort(string $query, array $terms): bool { if (empty($terms)) { return false; } $normalized = trim(mb_strtolower($query)); if ($normalized === '') { return false; } // Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact matches. if ($this->queryHasExplicitIdentifier($normalized)) { return false; } return true; } /** * @param array $terms * @return array */ private function buildExpandedTerms(array $terms): array { $base = $this->limitTerms($terms); if (empty($base)) { return []; } return $this->limitTerms([ ...$base, ...$this->expandLexicalSynonyms($base), ]); } private function queryHasExplicitIdentifier(string $normalizedQuery): bool { if ($normalizedQuery === '') { return false; } if (str_contains($normalizedQuery, '@')) { return true; } if (preg_match('/(?:^|[\s#])(order|invoice|booking|customer|kunde|vehicle|subuser|user)[\s:#-]*\d{3,}/iu', $normalizedQuery)) { return true; } if (preg_match('/\b\d{5,}\b/', $normalizedQuery)) { return true; } if (preg_match('/\b[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\b/i', $normalizedQuery)) { return true; } return false; } /** * @param array $terms */ private function buildBooleanFullTextQuery(array $terms): ?string { $parts = []; foreach ($terms as $term) { $normalized = trim(mb_strtolower((string)$term)); if ($normalized === '' || mb_strlen($normalized) < 3) { return null; } $sanitized = preg_replace('/[^\p{L}\p{N}_]+/u', '', $normalized); if (!is_string($sanitized) || $sanitized === '' || mb_strlen($sanitized) < 3) { return null; } $parts[] = '+' . $sanitized . '*'; } return empty($parts) ? null : implode(' ', array_values(array_unique($parts))); } /** * @param array $terms * @return array */ private function expandLexicalSynonyms(array $terms): array { $synonyms = [ 'rabat' => ['discount', 'discounts'], 'rabatordning' => ['discount'], 'rabatter' => ['discounts', 'discount'], 'discount' => ['rabat'], 'discounts' => ['rabat'], 'kunde' => ['customer', 'customers'], 'kunder' => ['customer', 'customers'], 'faktura' => ['invoice', 'invoices'], 'fakturaer' => ['invoice', 'invoices'], ]; $expanded = []; foreach ($terms as $term) { $term = trim(mb_strtolower((string)$term)); if ($term === '' || !isset($synonyms[$term])) { continue; } foreach ($synonyms[$term] as $synonym) { $expanded[] = $synonym; } } return array_values(array_unique($expanded)); } private function isEconomicCustomerIndexAvailable(): bool { return $this->tableExists(system_search_economic_customer_index::TABLE); } private function tokenize(string $query): array { $query = trim(mb_strtolower($query)); if ($query === '') { return []; } $parts = preg_split('/[^\p{L}\p{N}_]+/u', $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 limitTerms(array $terms): array { $normalized = []; foreach ($terms as $term) { if (!is_string($term)) { continue; } $value = trim(mb_strtolower($term)); if ($value === '' || mb_strlen($value) < 2) { continue; } if (mb_strlen($value) > $this->maxTermLength) { $value = mb_substr($value, 0, $this->maxTermLength); } $normalized[] = $value; if (count($normalized) >= $this->maxExpandedTerms) { break; } } return array_values(array_unique($normalized)); } private function permissionContextFingerprint( array $permissionsCatalogAll, array $permissionsCatalogOwn, array $moduleConfigVisibility ): string { $all = []; foreach ($permissionsCatalogAll as $permission => $description) { if (!is_string($permission) || $permission === '') { continue; } $all[$permission] = is_string($description) ? $description : (string)$description; } ksort($all); $own = array_values(array_unique(array_filter(array_map(static fn($v) => is_string($v) ? trim($v) : '', $permissionsCatalogOwn)))); sort($own); $visibility = []; foreach ($moduleConfigVisibility as $module => $visible) { if (!is_string($module) || $module === '') { continue; } $visibility[$module] = (bool)$visible; } ksort($visibility); return md5(json_encode([ 'all' => $all, 'own' => $own, 'visibility' => $visibility, 'v' => 1, ], JSON_UNESCAPED_UNICODE)); } /** * @param array $activeTypes * @return array */ private function relevantSourceTables(array $activeTypes): array { $tables = []; foreach ($activeTypes as $entityType) { $tables = [...$tables, ...system_search_registry::sourceTablesForEntityType($entityType)]; if ($this->entityTypeUsesCustomerContext($entityType)) { $tables[] = system_search_economic_customer_index::TABLE; } } return array_values(array_unique(array_filter($tables, static fn($table) => is_string($table) && $table !== ''))); } private function entityTypeUsesCustomerContext(string $entityType): bool { $entityType = trim(mb_strtolower($entityType)); if (in_array($entityType, ['objects', 'orders', 'order_items', 'invoices', 'vehicles', 'customers', 'employees', 'customer_discounts', 'customer_fixed_prices'], true)) { return true; } $config = $this->genericEntityConfigs()[$entityType] ?? null; return is_array($config) && isset($config['customer_field']) && is_string($config['customer_field']) && trim($config['customer_field']) !== ''; } 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 system_search_registry::allEntityTypes(); } 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; } }