2052 lines
81 KiB
PHP
2052 lines
81 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 = [];
|
|
|
|
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();
|
|
} 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;
|
|
$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,
|
|
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
|
|
'v' => 7,
|
|
], 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
|
|
);
|
|
|
|
$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
|
|
);
|
|
} 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()
|
|
));
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
$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> $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 {
|
|
if ($this->isGenericEntityType($entityType)) {
|
|
return $this->searchGenericEntity(
|
|
$entityType,
|
|
$terms,
|
|
$entityBoost,
|
|
$ownOnly,
|
|
$ownCustomerNumber,
|
|
$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 => [],
|
|
};
|
|
}
|
|
|
|
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',
|
|
['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', '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' => $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,
|
|
], $row),
|
|
];
|
|
}, $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' => $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
|
|
{
|
|
$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',
|
|
'u.customer_number',
|
|
'u.display_name',
|
|
...$this->joinTemporalSelectFields('price_overrides', 'po'),
|
|
];
|
|
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', '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',
|
|
'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,
|
|
'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
|
|
{
|
|
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' => $this->augmentPayloadWithTemporal([
|
|
'id' => (int)$row['id'],
|
|
'object_type' => $row['object_type'] ?? null,
|
|
'object_id' => $row['object_id'] ?? null,
|
|
], $row),
|
|
];
|
|
}, $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> $forcedCustomerNumbers
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function searchGenericEntity(
|
|
string $entityType,
|
|
array $terms,
|
|
int $entityBoost,
|
|
bool $ownOnly,
|
|
?int $ownCustomerNumber,
|
|
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'];
|
|
}
|
|
|
|
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,
|
|
$fixedConditions
|
|
);
|
|
|
|
$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 ($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[] = [
|
|
'entity_type' => $entityType,
|
|
'entity_id' => $entityId,
|
|
'title' => $title,
|
|
'description' => $description,
|
|
'customer_number' => ($customerField !== null && isset($row[$customerField])) ? $this->toIntOrNull($row[$customerField]) : 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 [
|
|
'bookings' => ['table' => 'bookings', 'customer_field' => 'customer_number', 'department_field' => 'department'],
|
|
'bookings_new' => ['table' => 'bookings_new', 'customer_field' => 'customer_number', 'department_field' => 'department'],
|
|
'branding' => ['table' => 'branding'],
|
|
'categories' => ['table' => 'categories'],
|
|
'currency_conversion_rates' => ['table' => 'currency_conversion_rates'],
|
|
'customer_codes' => ['table' => 'customer_codes'],
|
|
'customer_default_department' => ['table' => 'customer_default_department', 'customer_field' => 'customer_number', 'department_field' => 'department'],
|
|
'customer_notes' => ['table' => 'customer_notes', 'customer_field' => 'customer_id'],
|
|
'customer_vehicles_addons' => ['table' => 'customer_vehicles_addons'],
|
|
'department_categories' => ['table' => 'department_categories', 'department_field' => 'department'],
|
|
'department_daily_reports' => ['table' => 'department_daily_reports', 'department_field' => 'department_id'],
|
|
'department_gates' => ['table' => 'department_gates', 'department_field' => 'department'],
|
|
'department_goals' => ['table' => 'goals'],
|
|
'department_lanes' => ['table' => 'department_lanes', 'department_field' => 'department'],
|
|
'department_notification_sms' => ['table' => 'department_notification_sms', 'department_field' => 'department_id'],
|
|
'department_relays' => ['table' => 'department_relays', 'department_field' => 'department'],
|
|
'department_selfserve_condition_rules' => ['table' => 'department_selfserve_condition_rules'],
|
|
'department_selfserve_conditions' => ['table' => 'department_selfserve_conditions', 'department_field' => 'department'],
|
|
'department_selfserve_questions' => ['table' => 'department_selfserve_questions', 'department_field' => 'department'],
|
|
'department_selfserve_tasks' => ['table' => 'department_selfserve_tasks', 'department_field' => 'department'],
|
|
'department_selfserve_vehicle_conditions' => ['table' => 'department_selfserve_vehicle_conditions', 'customer_field' => 'customer_id', 'department_field' => 'department'],
|
|
'department_time_bookings_entries' => ['table' => 'department_time_bookings_entries', 'department_field' => 'department'],
|
|
'department_time_bookings_opening_hours' => ['table' => 'department_time_bookings_opening_hours', 'department_field' => 'department'],
|
|
'department_time_bookings_types' => ['table' => 'department_time_bookings_types', 'department_field' => 'department'],
|
|
'department_variables' => ['table' => 'department_variables'],
|
|
'fxratesapi_conversion_rates' => ['table' => 'fxratesapi_conversion_rates'],
|
|
'module_action_logs' => ['table' => 'module_usage_logs'],
|
|
'motorapi_lookups' => ['table' => 'motorapi_lookups'],
|
|
'notifications' => ['table' => 'notifications'],
|
|
'order_bookings' => ['table' => 'order_bookings', 'customer_field' => 'customer_number', 'department_field' => 'department'],
|
|
'plate_scanners' => ['table' => 'plate_scanners', 'department_field' => 'department_id'],
|
|
'plate_scans' => ['table' => 'plate_scans'],
|
|
'product_options' => ['table' => 'products_options'],
|
|
'products' => ['table' => 'products'],
|
|
'users' => [
|
|
'table' => 'users',
|
|
'customer_field' => 'customer_number',
|
|
'title_fields' => ['display_name', 'email', 'customer_number', 'id'],
|
|
'description_fields' => ['email', 'phone', 'role'],
|
|
],
|
|
'stripe_module_customers' => ['table' => 'stripe_module_customers', 'customer_field' => 'customer_id'],
|
|
'stripe_module_orders' => ['table' => 'stripe_module_orders', 'customer_field' => 'customer_id', 'exclude_columns' => ['url']],
|
|
'stripe_payment_intents' => ['table' => 'stripe_payment_intents', 'exclude_columns' => ['client_secret', 'data']],
|
|
'subuser_grants' => ['table' => 'subuser_grants', 'customer_field' => 'billing_customer_number'],
|
|
'xlvask_customers' => ['table' => 'xlvask_customers'],
|
|
'xlvask_potential_order_matches' => ['table' => 'xlvask_potential_order_matches', 'customer_field' => 'customer_number', 'department_field' => 'department'],
|
|
'xlvask_usage_log_wash_items' => ['table' => 'xlvask_usage_log_wash_items'],
|
|
'xlvask_usage_logs' => ['table' => 'xlvask_usage_logs'],
|
|
'xlvask_vehicle_types' => ['table' => 'xlvask_vehicle_types'],
|
|
'xlvask_vehicles' => ['table' => 'xlvask_vehicles'],
|
|
];
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 [];
|
|
}
|
|
$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)) {
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
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 (str_contains($normalized, '@')) {
|
|
return false;
|
|
}
|
|
if (preg_match('/(?:^|[\s#])(order|invoice|booking|customer|kunde|vehicle|subuser|user)[\s:#-]*\d{3,}/iu', $normalized)) {
|
|
return false;
|
|
}
|
|
if (preg_match('/\b\d{5,}\b/', $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 (preg_match('/\b(find|show|search|looking|need|want|where|which|with|without|from|between|for|unpaid|overdue|rabat|discount|faktura|invoice|kunde|customer|orders?|vehicles?)\b/iu', $normalized)) {
|
|
return true;
|
|
}
|
|
|
|
return mb_strlen($normalized) >= 24 && count($terms) >= 3;
|
|
}
|
|
|
|
/**
|
|
* @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('/[^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 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));
|
|
}
|
|
|
|
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 array_values(array_unique([
|
|
'objects',
|
|
'module_config',
|
|
'orders',
|
|
'order_items',
|
|
'customers',
|
|
'employees',
|
|
'subusers',
|
|
'customer_discounts',
|
|
'customer_fixed_prices',
|
|
'departments',
|
|
'permissions',
|
|
'roles',
|
|
'invoices',
|
|
'vehicles',
|
|
...array_keys($this->genericEntityConfigs()),
|
|
]));
|
|
}
|
|
|
|
private function taxonomy(array $activeTypes): array
|
|
{
|
|
$aliases = [
|
|
'customers' => ['customer', 'account', 'company', 'kunde'],
|
|
'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', 'rabat'],
|
|
'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'],
|
|
'bookings' => ['booking', 'wash booking'],
|
|
'bookings_new' => ['new booking', 'booking queue'],
|
|
'customer_notes' => ['customer note', 'note'],
|
|
'order_bookings' => ['order booking', 'scheduled order'],
|
|
'products' => ['product', 'service'],
|
|
'product_options' => ['product option', 'addon', 'add on'],
|
|
'plate_scans' => ['plate scan', 'license plate scan'],
|
|
'plate_scanners' => ['plate scanner', 'license plate scanner'],
|
|
'notifications' => ['notification', 'alert'],
|
|
'users' => ['user', 'account user'],
|
|
'module_action_logs' => ['module log', 'action log'],
|
|
'motorapi_lookups' => ['motorapi lookup', 'plate lookup'],
|
|
'xlvask_customers' => ['xlvask customer'],
|
|
'xlvask_vehicles' => ['xlvask vehicle'],
|
|
'xlvask_usage_logs' => ['xlvask usage log'],
|
|
'department_daily_reports' => ['department daily report', 'daily report'],
|
|
];
|
|
$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;
|
|
}
|
|
}
|