Files
api/services/nginx/app/classes/system_search_service.php
T

3017 lines
115 KiB
PHP

<?php
namespace classes;
use interfaces\system_search_intent_parser_i;
use Throwable;
class system_search_service
{
private system_search_intent_parser_i $intentParser;
private int $lowConfidenceResultThreshold = 5;
private int $lowConfidenceTopScoreThreshold = 60;
private int $defaultEntityFetchLimit = 200;
private int $maxExpandedTerms = 24;
private int $maxTermLength = 64;
private int $recencyScoreTolerance = 12;
private array $rankingBoostByType = [
'invoices' => 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(?system_search_intent_parser_i $intentParser = null)
{
$this->intentParser = $intentParser ?? new system_search_openai_intent_parser();
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);
$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,
'dept' => $allowedDepartmentIds,
'assoc' => $includeAssociations,
'dbg' => $debugIntent,
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)),
'v' => 12,
], 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->buildExpandedTerms($this->tokenize($query));
$entityBoost = [];
$initialResults = $this->executeLexicalSearch(
$activeTypes,
$terms,
$entityBoost,
$ownOnlyTypes,
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds
);
$intentAssociationHint = false;
$intentMeta = [
'invoked' => false,
'source' => 'none',
'status' => 'skipped',
'confidence' => 0.0,
'expanded_terms' => $terms,
'entity_hints' => [],
'fallback_reason' => null,
];
$shouldInvokeIntent = !empty($terms) && (
$this->shouldInvokeIntentParser($initialResults)
|| $this->queryLooksIntentDriven($query, $terms)
);
if ($shouldInvokeIntent) {
$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'] ?? []);
$intentAssociationHint = (bool)($intent['association_hint'] ?? false);
if (!empty($intent['success'])) {
$intentMeta['status'] = 'ok';
$boostedTypes = array_values(array_intersect($activeTypes, (array)($intent['entity_hints'] ?? [])));
foreach ($boostedTypes as $boostedType) {
$entityBoost[$boostedType] = 25;
}
$expandedTerms = $this->buildExpandedTerms([
...$terms,
...$this->tokenize((string)($intent['normalized_query'] ?? '')),
...$this->tokenize(implode(' ', (array)($intent['aliases'] ?? []))),
...$this->hintAliasTerms($boostedTypes, $taxonomy),
]);
$intentMeta['expanded_terms'] = $expandedTerms;
$initialResults = $this->executeLexicalSearch(
$activeTypes,
$expandedTerms,
$entityBoost,
$ownOnlyTypes,
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds
);
} else {
$intentMeta['status'] = 'fallback';
}
}
if ($includeAssociations) {
$customerNumbers = [];
foreach ($initialResults as $result) {
if (!isset($result['customer_number'])) {
continue;
}
if ($result['entity_type'] !== 'customers' && !$intentAssociationHint) {
continue;
}
$customerNumbers[] = (int)$result['customer_number'];
}
$customerNumbers = array_values(array_unique(array_filter($customerNumbers)));
if (count($customerNumbers) > 15) {
$customerNumbers = array_slice($customerNumbers, 0, 15);
}
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);
}
$initialResults = $this->mergeResults($initialResults, $associated);
}
}
}
}
$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 = $scoreA + $this->rankingBoost($a) - $this->rankingPenalty($a);
$effectiveScoreB = $scoreB + $this->rankingBoost($b) - $this->rankingPenalty($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']);
});
$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<int, string> $activeTypes
* @param array<int, string> $terms
* @param array<string, int> $entityBoost
* @param array<int, string> $ownOnlyTypes
* @param int|null $ownCustomerNumber
* @param array<string, string> $permissionsCatalogAll
* @param array<int, string> $permissionsCatalogOwn
* @param array<string, bool> $moduleConfigVisibility
* @param array<int, int> $allowedDepartmentIds
* @param array<int, int> $forcedCustomerNumbers
* @return array<int, array<string, mixed>>
*/
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
);
if (empty($rows)) {
$rows = $this->searchEntity(
$entityType,
$terms,
$boost,
$ownOnly,
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$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<int, string> $terms
* @param array<string, string> $permissionsCatalogAll
* @param array<int, string> $permissionsCatalogOwn
* @param array<string, bool> $moduleConfigVisibility
* @param array<int, int> $allowedDepartmentIds
* @param array<int, int> $forcedCustomerNumbers
* @return array<int, array<string, mixed>>
*/
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<int, string> $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<int, string> $terms
* @param array<string, bool> $moduleConfigVisibility
* @param array<int, int> $allowedDepartmentIds
* @param array<int, int> $forcedCustomerNumbers
* @return array<int, array<string, mixed>>
*/
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<int, mixed> $entityIds
* @return array<string, array{name:?string,created_at:?string,closed_at:?string}>
*/
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<int, string>
*/
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<int, string> $terms
* @param array<int, int> $allowedDepartmentIds
* @param array<int, int> $forcedCustomerNumbers
* @return array<int, array<string, mixed>>
*/
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<int, string> $columns
* @param array<int, string> $excludeColumns
* @return array<int, string>
*/
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<string, array<string, mixed>>
*/
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<int, int> $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<int, string> $candidateFields
* @param array<int, string> $searchFields
* @param array<int, string> $terms
* @param array<int, int> $customerNumbers
* @param string|null $customerField
* @param string $customerFieldMode
* @param array<string, mixed> $fixedConditions
* @param array<int, int> $departmentIds
* @param string|null $departmentField
* @return array<int, array<string, mixed>>
*/
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<int, string> $selectFields
* @param array<int, string> $searchFields
* @param array<int, string> $terms
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, string> $terms
* @return array<int, string>
*/
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<int, string> $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<int, string> $fields
* @return array<int, string>
*/
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<int, string>
*/
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<string, mixed> $payload
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
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<string, mixed> $row
* @param array<int, string> $terms
* @return array<string, mixed>
*/
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<string, mixed> $row
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>}
*/
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<string, mixed> $row
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>}
*/
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<string, mixed> $row
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>}
*/
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<string, mixed> $row
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>}
*/
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<int, int> $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<int, int> $customerNumbers
* @return array<int, array<string, mixed>>
*/
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<string, mixed>|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<string, mixed>|null
*/
private function customerContext(?int $customerNumber): ?array
{
if ($customerNumber === null || $customerNumber <= 0) {
return null;
}
$this->primeCustomerContexts([$customerNumber]);
return $this->customerContextCache[$customerNumber] ?? null;
}
/**
* @param array<string, mixed> $result
* @return array<string, mixed>
*/
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<string, mixed> $result
* @param array<string, mixed> $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<string, mixed> $payload
* @param array<string, mixed>|null $context
* @return array<string, mixed>
*/
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 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<int, string> $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 intent.
if ($this->queryHasExplicitIdentifier($normalized)) {
return false;
}
return true;
}
/**
* @param array<int, string> $terms
* @return array<int, string>
*/
private function buildExpandedTerms(array $terms): array
{
$base = $this->limitTerms($terms);
if (empty($base)) {
return [];
}
return $this->limitTerms([
...$base,
...$this->expandLexicalSynonyms($base),
]);
}
/**
* @param array<int, string> $boostedTypes
* @param array<string, array<int, string>> $taxonomy
* @return array<int, string>
*/
private function hintAliasTerms(array $boostedTypes, array $taxonomy): array
{
$terms = [];
foreach ($boostedTypes as $type) {
$aliases = $taxonomy[$type] ?? [];
if (!is_array($aliases)) {
continue;
}
foreach ($aliases as $alias) {
if (!is_string($alias) || trim($alias) === '') {
continue;
}
$terms = [...$terms, ...$this->tokenize($alias)];
if (count($terms) >= 12) {
return array_slice(array_values(array_unique($terms)), 0, 12);
}
}
}
return array_slice(array_values(array_unique($terms)), 0, 12);
}
/**
* Detect natural-language style queries where intent parsing is valuable
* even when lexical score looks strong.
*
* @param array<int, string> $terms
*/
private function queryLooksIntentDriven(string $query, array $terms): bool
{
$normalized = trim(mb_strtolower($query));
if ($normalized === '' || count($terms) < 2) {
return false;
}
if ($this->queryHasExplicitIdentifier($normalized)) {
return false;
}
$hasIntentVerb = preg_match('/\b(find|show|search|looking|need|want|where|which)\b/iu', $normalized) === 1;
$hasRelationalLanguage = preg_match('/\b(with|without|from|between|for|unpaid|overdue|rabat|discount|faktura|invoice|kunde|customer|orders?|vehicles?)\b/iu', $normalized) === 1;
$hasStrongDomainLanguage = preg_match('/\b(unpaid|overdue|rabat|discount|faktura|invoice)\b/iu', $normalized) === 1;
if ($hasStrongDomainLanguage && count($terms) >= 2) {
return true;
}
if ($hasIntentVerb && count($terms) >= 3) {
return true;
}
if ($hasRelationalLanguage && count($terms) >= 3 && mb_strlen($normalized) >= 16) {
return true;
}
return mb_strlen($normalized) >= 28 && count($terms) >= 4;
}
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<int, string> $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<int, string> $terms
* @return array<int, string>
*/
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<int, string> $activeTypes
* @return array<int, string>
*/
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 taxonomy(array $activeTypes): array
{
$aliases = system_search_registry::taxonomyAliases();
$taxonomy = [];
foreach ($activeTypes as $type) {
$resolved = $aliases[$type] ?? [];
if (empty($resolved)) {
$human = str_replace('_', ' ', $type);
$singular = rtrim($human, 's');
$resolved = array_values(array_unique(array_filter([$human, $singular], static fn($v) => is_string($v) && $v !== '')));
}
$taxonomy[$type] = $resolved;
}
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;
}
}