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

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