From 1e1576fe87644a8b6c30dd53ca84b742a355abaf Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Fri, 13 Mar 2026 01:52:33 +0100 Subject: [PATCH] Add `system_search_economic_customer_index` class with table creation, refresh logic, and integration into system search fields, synonyms, and entity matching. Update service logic, unit tests, and cron tasks accordingly. --- openapi.yaml | 4 +- .../system_search_economic_customer_index.php | 243 ++++++++++ .../system_search_openai_intent_parser.php | 5 +- .../app/classes/system_search_service.php | 455 ++++++++++++++++-- ...mSearchEconomicCustomerIndexWiringTest.php | 23 + .../Search/SystemSearchOpenApiSpecTest.php | 1 + .../SystemSearchServiceIntentFlowTest.php | 357 +++++++++++++- 7 files changed, 1036 insertions(+), 52 deletions(-) create mode 100644 services/nginx/app/classes/system_search_economic_customer_index.php create mode 100644 services/nginx/app/tests/Unit/Search/SystemSearchEconomicCustomerIndexWiringTest.php diff --git a/openapi.yaml b/openapi.yaml index 0c354c13..8d7c4248 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -7751,7 +7751,7 @@ paths: tags: - Search summary: System-wide search - description: Search across all supported entities with permission-aware filtering and optional intent parsing debug metadata. Customer records and customer-related entities are matched against a local e-conomic customer index (name/address/email/CVR) that is refreshed by cron. + description: Search across all supported entities with permission-aware filtering and optional intent parsing debug metadata. Customer records and customer-related entities are matched against a local e-conomic customer index (name/address/email/CVR) that is refreshed by cron. Intent parsing is invoked adaptively when lexical confidence is low or when the query looks intent-driven. Results are ordered by relevance, with recent records preferred when relevance is comparable. operationId: systemWideSearchGet parameters: - in: query @@ -7826,7 +7826,7 @@ paths: tags: - Search summary: System-wide search - description: Search across all supported entities using JSON request payload. Customer-related matches include local e-conomic customer index fields. + description: Search across all supported entities using JSON request payload. Customer-related matches include local e-conomic customer index fields. Intent parsing may run adaptively for intent-driven natural-language queries. Results are ordered by relevance, with recent records preferred when relevance is comparable. operationId: systemWideSearchPost requestBody: required: true diff --git a/services/nginx/app/classes/system_search_economic_customer_index.php b/services/nginx/app/classes/system_search_economic_customer_index.php new file mode 100644 index 00000000..72afa4cc --- /dev/null +++ b/services/nginx/app/classes/system_search_economic_customer_index.php @@ -0,0 +1,243 @@ +query($sql); + self::$initialized = true; + } + + /** + * Rebuild local e-conomic customer index from local users + cached/live e-conomic snapshots. + * + * @return array + */ + public static function refreshIndex(bool $refreshEconomicData = false): array + { + self::ensureTable(); + + global $db; + if (!is_object($db) || !method_exists($db, 'query') || !property_exists($db, 'conn')) { + return [ + 'processed' => 0, + 'upserted' => 0, + 'deleted' => 0, + 'errors' => 0, + ]; + } + + $stats = [ + 'processed' => 0, + 'upserted' => 0, + 'deleted' => 0, + 'errors' => 0, + ]; + + $result = $db->query("SELECT `id`, `customer_number`, `display_name`, `email`, `phone` + FROM `users` + WHERE `customer_number` IS NOT NULL + AND `customer_number` <> 0"); + if (!($result instanceof \mysqli_result)) { + return $stats; + } + + $rows = $db->fetch_all($result); + $seenCustomerNumbers = []; + + foreach ($rows as $row) { + $stats['processed']++; + + $customerNumber = (int)($row['customer_number'] ?? 0); + if ($customerNumber <= 0) { + continue; + } + $seenCustomerNumbers[$customerNumber] = true; + + $economic = []; + try { + $tmpUser = new users_o(); + $tmpUser->getUserByCustomerNumber($customerNumber); + if ($refreshEconomicData) { + $tmpUser->getCustomerEcocomicData($customerNumber); + } + $cached = $tmpUser->getCached('economic_customer'); + if (!$cached && !$refreshEconomicData) { + $tmpUser->getCustomerEcocomicData($customerNumber); + $cached = $tmpUser->getCached('economic_customer'); + } + $economic = self::normalizeEconomicSnapshot($cached); + } catch (Throwable) { + $stats['errors']++; + } + + $localDisplayName = self::toNullableString($row['display_name'] ?? null); + $localEmail = self::toNullableString($row['email'] ?? null); + $localPhone = self::toNullableString($row['phone'] ?? null); + + $economicName = self::toNullableString($economic['name'] ?? null); + $economicAddress = self::toNullableString($economic['address'] ?? null); + $economicCity = self::toNullableString($economic['city'] ?? null); + $economicZip = self::toNullableString($economic['zip'] ?? null); + $economicEmail = self::toNullableString($economic['email'] ?? null); + $economicCvr = self::toNullableString($economic['corporateIdentificationNumber'] ?? null); + $economicMobilePhone = self::toNullableString($economic['mobilePhone'] ?? null); + + $searchText = trim(implode(' ', array_values(array_filter([ + $customerNumber > 0 ? (string)$customerNumber : null, + $localDisplayName, + $localEmail, + $localPhone, + $economicName, + $economicAddress, + $economicCity, + $economicZip, + $economicEmail, + $economicCvr, + $economicMobilePhone, + ], static fn($v) => is_string($v) && trim($v) !== '')))); + if ($searchText === '') { + $searchText = null; + } + + $sql = "INSERT INTO `" . self::TABLE . "` ( + `customer_number`, + `user_id`, + `local_display_name`, + `local_email`, + `local_phone`, + `economic_name`, + `economic_address`, + `economic_city`, + `economic_zip`, + `economic_email`, + `economic_cvr`, + `economic_mobile_phone`, + `search_text` + ) VALUES ( + " . (int)$customerNumber . ", + " . (int)($row['id'] ?? 0) . ", + " . self::sqlNullableString($localDisplayName) . ", + " . self::sqlNullableString($localEmail) . ", + " . self::sqlNullableString($localPhone) . ", + " . self::sqlNullableString($economicName) . ", + " . self::sqlNullableString($economicAddress) . ", + " . self::sqlNullableString($economicCity) . ", + " . self::sqlNullableString($economicZip) . ", + " . self::sqlNullableString($economicEmail) . ", + " . self::sqlNullableString($economicCvr) . ", + " . self::sqlNullableString($economicMobilePhone) . ", + " . self::sqlNullableString($searchText) . " + ) ON DUPLICATE KEY UPDATE + `user_id` = VALUES(`user_id`), + `local_display_name` = VALUES(`local_display_name`), + `local_email` = VALUES(`local_email`), + `local_phone` = VALUES(`local_phone`), + `economic_name` = VALUES(`economic_name`), + `economic_address` = VALUES(`economic_address`), + `economic_city` = VALUES(`economic_city`), + `economic_zip` = VALUES(`economic_zip`), + `economic_email` = VALUES(`economic_email`), + `economic_cvr` = VALUES(`economic_cvr`), + `economic_mobile_phone` = VALUES(`economic_mobile_phone`), + `search_text` = VALUES(`search_text`), + `updated_at` = CURRENT_TIMESTAMP"; + $db->query($sql); + $stats['upserted']++; + } + + $seen = array_keys($seenCustomerNumbers); + if (empty($seen)) { + $db->query("DELETE FROM `" . self::TABLE . "`"); + $stats['deleted'] = self::safeAffectedRows(); + return $stats; + } + + $in = implode(',', array_map('intval', $seen)); + $db->query("DELETE FROM `" . self::TABLE . "` WHERE `customer_number` NOT IN (" . $in . ")"); + $stats['deleted'] = self::safeAffectedRows(); + + return $stats; + } + + /** + * @return array + */ + private static function normalizeEconomicSnapshot(mixed $snapshot): array + { + if (is_object($snapshot)) { + return get_object_vars($snapshot); + } + if (is_array($snapshot)) { + return $snapshot; + } + return []; + } + + private static function toNullableString(mixed $value): ?string + { + if ($value === null) { + return null; + } + $string = trim((string)$value); + return $string === '' ? null : $string; + } + + private static function sqlNullableString(?string $value): string + { + global $db; + if ($value === null) { + return 'NULL'; + } + return "'" . $db->escape_string($value) . "'"; + } + + private static function safeAffectedRows(): int + { + global $db; + if (!is_object($db) || !property_exists($db, 'conn') || !is_object($db->conn)) { + return 0; + } + return max(0, (int)($db->conn->affected_rows ?? 0)); + } +} diff --git a/services/nginx/app/classes/system_search_openai_intent_parser.php b/services/nginx/app/classes/system_search_openai_intent_parser.php index 91aae9c9..81c280ef 100644 --- a/services/nginx/app/classes/system_search_openai_intent_parser.php +++ b/services/nginx/app/classes/system_search_openai_intent_parser.php @@ -109,7 +109,10 @@ class system_search_openai_intent_parser implements system_search_intent_parser_ . "Rules:\n" . "- Keep output concise and valid JSON only.\n" . "- Do not invent entity types not listed in allowed_entity_types.\n" - . "- aliases should contain user-friendly alternative terms.\n" + . "- Infer what the user is trying to find, not just literal words.\n" + . "- aliases should contain user-friendly and backend-friendly equivalent terms.\n" + . "- Include cross-language/domain synonyms when likely (example: Danish 'rabat' -> 'discount').\n" + . "- If user references a customer/company by name, include hints that help find related invoices/orders/discounts.\n" . "- confidence must be between 0 and 1.\n" . "- association_hint should be true if related records likely needed.\n\n" . "Context:\n" diff --git a/services/nginx/app/classes/system_search_service.php b/services/nginx/app/classes/system_search_service.php index bda135df..a059f96b 100644 --- a/services/nginx/app/classes/system_search_service.php +++ b/services/nginx/app/classes/system_search_service.php @@ -13,6 +13,25 @@ class system_search_service 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) @@ -89,7 +108,7 @@ class system_search_service 'assoc' => $includeAssociations, 'dbg' => $debugIntent, 'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility), - 'v' => 2, + 'v' => 7, ], JSON_UNESCAPED_UNICODE)); $cached = system_search_cache::getQuery($queryCacheHash); @@ -111,6 +130,7 @@ class system_search_service $moduleConfigVisibility ); + $intentAssociationHint = false; $intentMeta = [ 'invoked' => false, 'source' => 'none', @@ -121,7 +141,11 @@ class system_search_service 'fallback_reason' => null, ]; - if ($this->shouldInvokeIntentParser($initialResults) && !empty($terms)) { + $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); @@ -129,20 +153,21 @@ class system_search_service $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'; - $expandedTerms = $this->buildExpandedTerms([ - ...$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; } + $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, @@ -162,11 +187,18 @@ class system_search_service if ($includeAssociations) { $customerNumbers = []; foreach ($initialResults as $result) { - if ($result['entity_type'] === 'customers' && isset($result['customer_number'])) { - $customerNumbers[] = (int)$result['customer_number']; + 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, @@ -195,11 +227,38 @@ class system_search_service } } - 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']); + $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; } - return $b['score'] <=> $a['score']; + + 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); @@ -335,7 +394,14 @@ class system_search_service $customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $customerNumbers)) . ')'; } - $selectFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone']; + $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'; @@ -408,7 +474,7 @@ class system_search_service 'economic_mobile_phone', 'search_text', ], $terms) + $entityBoost, - 'payload' => [ + 'payload' => $this->augmentPayloadWithTemporal([ 'id' => (int)$row['id'], 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'display_name' => $row['display_name'] ?? null, @@ -421,7 +487,7 @@ class system_search_service 'economic_email' => $row['economic_email'] ?? null, 'economic_cvr' => $row['economic_cvr'] ?? null, 'economic_mobile_phone' => $row['economic_mobile_phone'] ?? null, - ], + ], $row), ]; }, $rows); } @@ -431,7 +497,7 @@ class system_search_service $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', ...$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) : '') @@ -445,12 +511,12 @@ class system_search_service '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' => [ + 'payload' => $this->augmentPayloadWithTemporal([ 'id' => (int)$row['id'], 'display_name' => $row['display_name'] ?? null, 'email' => $row['email'] ?? null, 'phone' => $row['phone'] ?? null, - ], + ], $row), ]; }, $rows); } @@ -477,13 +543,13 @@ class system_search_service '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' => [ + '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); } @@ -500,7 +566,15 @@ class system_search_service $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 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 @@ -514,12 +588,12 @@ class system_search_service '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' => [ + '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); } @@ -545,12 +619,12 @@ class system_search_service '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' => [ + '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); } @@ -576,12 +650,12 @@ class system_search_service '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' => [ + '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); } @@ -595,7 +669,15 @@ class system_search_service $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_country_code', + 's.phone', + ...$this->joinTemporalSelectFields('subusers', 's'), + ], ['s.id', 's.username', 's.name', 's.email', 's.phone'], $terms, $where @@ -608,12 +690,12 @@ class system_search_service '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' => [ + 'payload' => $this->augmentPayloadWithTemporal([ 'id' => (int)$row['id'], 'username' => $row['username'] ?? null, 'name' => $row['name'] ?? null, 'email' => $row['email'] ?? null, - ], + ], $row), ]; }, $rows); } @@ -628,7 +710,16 @@ class system_search_service } $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']; + $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()) { @@ -693,14 +784,14 @@ class system_search_service 'percentage', 'user_id', ], $terms) + $entityBoost, - 'payload' => [ + '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); } @@ -716,7 +807,13 @@ class system_search_service } $fromClause = 'customer_fixed_pricing cfp'; - $selectFields = ['cfp.id', 'cfp.customer_number', 'cfp.price', 'cfp.description']; + $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()) { @@ -779,14 +876,14 @@ class system_search_service 'economic_mobile_phone', 'search_text', ], $terms) + $entityBoost, - 'payload' => [ + '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); } @@ -807,13 +904,13 @@ class system_search_service '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' => [ + '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); } @@ -834,11 +931,11 @@ class system_search_service 'title' => (string)($row['name'] ?: ('Role #' . $row['id'])), 'description' => (string)($row['description'] ?? ''), 'score' => $this->scoreRow($row, ['id', 'name', 'description'], $terms) + $entityBoost, - 'payload' => [ + 'payload' => $this->augmentPayloadWithTemporal([ 'id' => (int)$row['id'], 'name' => $row['name'] ?? null, 'description' => $row['description'] ?? null, - ], + ], $row), ]; }, $rows); } @@ -903,11 +1000,11 @@ class system_search_service 'title' => $module . '.' . $variable, 'description' => (string)($row['type'] ?? ''), 'score' => $score, - 'payload' => [ + 'payload' => $this->augmentPayloadWithTemporal([ 'module' => $module, 'variable' => $variable, 'type' => $row['type'] ?? null, - ], + ], $row), ]; } return $filtered; @@ -935,11 +1032,11 @@ class system_search_service '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' => [ + 'payload' => $this->augmentPayloadWithTemporal([ 'id' => (int)$row['id'], 'object_type' => $row['object_type'] ?? null, 'object_id' => $row['object_id'] ?? null, - ], + ], $row), ]; }, $rows); } @@ -1136,7 +1233,10 @@ class system_search_service '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' => array_intersect_key($row, array_flip($selectFields)), + 'payload' => $this->augmentPayloadWithTemporal( + array_intersect_key($row, array_flip([...$selectFields, 'updated_at', 'created_at'])), + $row + ), ]; } @@ -1276,6 +1376,7 @@ class system_search_service if (empty($fields)) { return []; } + $fields = $this->appendTemporalColumns($table, $fields); $searchable = array_values(array_intersect($searchFields, $fields)); if (empty($searchable)) { return []; @@ -1474,6 +1575,218 @@ class system_search_service return array_values($indexed); } + /** + * @param array $fields + * @return array + */ + private function appendTemporalColumns(string $table, array $fields): array + { + $columns = $this->getColumns($table); + foreach (['updated_at', 'created_at'] as $column) { + if (in_array($column, $columns, true) && !in_array($column, $fields, true)) { + $fields[] = $column; + } + } + return array_values(array_unique($fields)); + } + + /** + * @return array + */ + private function joinTemporalSelectFields(string $table, string $alias): array + { + $fields = []; + $columns = $this->getColumns($table); + $aliasPrefix = trim($alias) === '' ? '' : (trim($alias) . '.'); + foreach (['updated_at', 'created_at'] as $column) { + if (!in_array($column, $columns, true)) { + continue; + } + $fields[] = $aliasPrefix . $column . ' AS ' . $column; + } + return $fields; + } + + /** + * @param array $payload + * @param array $row + * @return array + */ + private function augmentPayloadWithTemporal(array $payload, array $row): array + { + foreach (['updated_at', 'created_at'] as $column) { + if (array_key_exists($column, $row)) { + $payload[$column] = $row[$column]; + } + } + return $payload; + } + + 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 $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 $terms * @return array @@ -1490,6 +1803,52 @@ class system_search_service ]); } + /** + * @param array $boostedTypes + * @param array> $taxonomy + * @return array + */ + 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 $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 $terms * @return array diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchEconomicCustomerIndexWiringTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchEconomicCustomerIndexWiringTest.php new file mode 100644 index 00000000..a62f81e9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Search/SystemSearchEconomicCustomerIndexWiringTest.php @@ -0,0 +1,23 @@ +not->toBeFalse(); + expect($serviceContent)->toContain('system_search_economic_customer_index::TABLE'); + expect($serviceContent)->toContain('sci.economic_name'); + expect($serviceContent)->toContain('searchCustomerDiscounts'); + expect($serviceContent)->toContain('searchCustomerFixedPrices'); + expect($serviceContent)->toContain('expandLexicalSynonyms'); + expect($serviceContent)->toContain("'rabat' => ['discount', 'discounts']"); +}); + +it('registers cron tasks that keep the e-conomic search index refreshed', function (): void { + $cronContent = file_get_contents(app_path('cron/Cron.php')); + + expect($cronContent)->not->toBeFalse(); + expect($cronContent)->toContain('SyncSystemSearchEconomicCustomerIndex'); + expect($cronContent)->toContain('$users_o->syncAllUsersEconomicCustomerDetails()'); + expect($cronContent)->toContain('system_search_economic_customer_index::refreshIndex(false)'); +}); + diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchOpenApiSpecTest.php index 98ca5c60..605dc793 100644 --- a/services/nginx/app/tests/Unit/Search/SystemSearchOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Search/SystemSearchOpenApiSpecTest.php @@ -47,4 +47,5 @@ it('documents e-conomic indexed customer matching and synonym behavior', functio expect($content)->toContain('local e-conomic customer index'); expect($content)->toContain('`rabat` -> `discount`'); + expect($content)->toContain('recent records preferred when relevance is comparable'); }); diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php index 601a3561..da45e7a3 100644 --- a/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php +++ b/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php @@ -366,7 +366,362 @@ it('expands danish discount wording into lexical discount synonyms', function () ]); $terms = $service->lexicalCalls[0]['terms'] ?? []; - expect($parser->calls)->toBe(0); + expect($parser->calls)->toBeGreaterThanOrEqual(1); expect($terms)->toContain('rabat'); expect($terms)->toContain('discount'); }); + +it('invokes parser for intent-driven natural-language queries even when lexical score is high', function (): void { + $parser = new FakeSystemSearchIntentParser([ + [ + 'success' => true, + 'normalized_query' => 'acme customer discount', + 'aliases' => ['discount', 'price override'], + 'entity_hints' => ['customer_discounts'], + 'confidence' => 0.82, + 'association_hint' => true, + 'fallback_reason' => null, + 'source' => 'openai', + ], + ]); + + $service = new TestableSystemSearchService($parser, [ + [ + ['entity_type' => 'customer_discounts', 'entity_id' => '1', 'title' => 'd1', 'score' => 95], + ['entity_type' => 'customer_discounts', 'entity_id' => '2', 'title' => 'd2', 'score' => 92], + ['entity_type' => 'customer_discounts', 'entity_id' => '3', 'title' => 'd3', 'score' => 90], + ['entity_type' => 'customer_discounts', 'entity_id' => '4', 'title' => 'd4', 'score' => 88], + ['entity_type' => 'customer_discounts', 'entity_id' => '5', 'title' => 'd5', 'score' => 86], + ], + [ + ['entity_type' => 'customer_discounts', 'entity_id' => '10', 'title' => 'improved', 'score' => 97], + ], + ]); + + $result = $service->search([ + 'query' => 'show me acme rabat options', + 'allowed_types' => ['customer_discounts'], + 'include_associations' => false, + 'debug_intent' => true, + ]); + + expect($parser->calls)->toBe(1); + expect(count($service->lexicalCalls))->toBe(2); + expect($result['meta']['intent_parser']['status'])->toBe('ok'); +}); + +it('uses association hints to pull related customer records from non-customer matches', function (): void { + $parser = new FakeSystemSearchIntentParser([ + [ + 'success' => true, + 'normalized_query' => 'pleno customer discount', + 'aliases' => ['discount'], + 'entity_hints' => ['customer_discounts', 'orders'], + 'confidence' => 0.85, + 'association_hint' => true, + 'fallback_reason' => null, + 'source' => 'openai', + ], + ]); + + $service = new TestableSystemSearchService($parser, [ + [ + [ + 'entity_type' => 'customer_discounts', + 'entity_id' => '44', + 'title' => 'Discount #44', + 'customer_number' => 777, + 'score' => 12, + ], + ], + [ + [ + 'entity_type' => 'customer_discounts', + 'entity_id' => '44', + 'title' => 'Discount #44', + 'customer_number' => 777, + 'score' => 95, + ], + ], + [ + [ + 'entity_type' => 'orders', + 'entity_id' => '9001', + 'title' => 'Order #9001', + 'customer_number' => 777, + 'score' => 40, + ], + ], + ]); + + $result = $service->search([ + 'query' => 'pleno rabat', + 'allowed_types' => ['customer_discounts', 'orders'], + 'include_associations' => true, + 'debug_intent' => true, + ]); + + expect($parser->calls)->toBe(1); + expect(count($service->lexicalCalls))->toBe(3); + expect($service->lexicalCalls[2]['forcedCustomerNumbers'])->toBe([777]); + + $types = array_map(static fn(array $row) => (string)$row['entity_type'], $result['results']); + expect($types)->toContain('orders'); + expect($result['meta']['intent_parser']['status'])->toBe('ok'); +}); + +it('prefers newer records when relevance scores are comparable', function (): void { + $parser = new FakeSystemSearchIntentParser(); + $service = new TestableSystemSearchService($parser, [[ + [ + 'entity_type' => 'bookings', + 'entity_id' => '1', + 'title' => 'Older booking', + 'score' => 90, + 'payload' => ['updated_at' => '2026-01-01 00:00:00'], + ], + [ + 'entity_type' => 'bookings', + 'entity_id' => '2', + 'title' => 'Newer booking', + 'score' => 89, + 'payload' => ['updated_at' => '2026-03-10 12:00:00'], + ], + ['entity_type' => 'bookings', 'entity_id' => '3', 'title' => 'B3', 'score' => 85], + ['entity_type' => 'bookings', 'entity_id' => '4', 'title' => 'B4', 'score' => 84], + ['entity_type' => 'bookings', 'entity_id' => '5', 'title' => 'B5', 'score' => 83], + ]]); + + $result = $service->search([ + 'query' => 'booking', + 'allowed_types' => ['bookings'], + 'include_associations' => false, + ]); + + expect($parser->calls)->toBe(0); + expect($result['results'][0]['entity_id'])->toBe('2'); +}); + +it('keeps explicit identifier matches ahead of newer but weaker records', function (): void { + $parser = new FakeSystemSearchIntentParser(); + $service = new TestableSystemSearchService($parser, [[ + [ + 'entity_type' => 'orders', + 'entity_id' => '100', + 'title' => 'Exact order', + 'score' => 90, + 'payload' => ['updated_at' => '2026-01-01 00:00:00'], + ], + [ + 'entity_type' => 'orders', + 'entity_id' => '101', + 'title' => 'Newer but weaker', + 'score' => 89, + 'payload' => ['updated_at' => '2026-03-12 00:00:00'], + ], + ['entity_type' => 'orders', 'entity_id' => '102', 'title' => 'O102', 'score' => 85], + ['entity_type' => 'orders', 'entity_id' => '103', 'title' => 'O103', 'score' => 84], + ['entity_type' => 'orders', 'entity_id' => '104', 'title' => 'O104', 'score' => 83], + ]]); + + $result = $service->search([ + 'query' => '12345', + 'allowed_types' => ['orders'], + 'include_associations' => false, + ]); + + expect($parser->calls)->toBe(0); + expect($result['results'][0]['entity_id'])->toBe('100'); +}); + +it('promotes invoices orders order bookings and customers in ranking', function (): void { + $parser = new FakeSystemSearchIntentParser(); + $service = new TestableSystemSearchService($parser, [[ + ['entity_type' => 'vehicles', 'entity_id' => '800', 'title' => 'Vehicle #800', 'score' => 97], + ['entity_type' => 'departments', 'entity_id' => '801', 'title' => 'Department #801', 'score' => 96], + ['entity_type' => 'invoices', 'entity_id' => '802', 'title' => 'Invoice #802', 'score' => 70], + ['entity_type' => 'orders', 'entity_id' => '803', 'title' => 'Order #803', 'score' => 69], + ['entity_type' => 'order_bookings', 'entity_id' => '804', 'title' => 'Order booking #804', 'score' => 68], + ['entity_type' => 'customers', 'entity_id' => '805', 'title' => 'Customer #805', 'score' => 67], + ]]); + + $result = $service->search([ + 'query' => '12345', + 'allowed_types' => ['vehicles', 'departments', 'invoices', 'orders', 'order_bookings', 'customers'], + 'include_associations' => false, + ]); + + $types = array_map(static fn(array $row): string => (string)$row['entity_type'], $result['results']); + expect($parser->calls)->toBe(0); + expect(array_slice($types, 0, 4))->toBe([ + 'invoices', + 'orders', + 'order_bookings', + 'customers', + ]); +}); + +it('never prioritizes cancelled bookings over active bookings', function (): void { + $parser = new FakeSystemSearchIntentParser(); + $service = new TestableSystemSearchService($parser, [[ + [ + 'entity_type' => 'bookings', + 'entity_id' => '500', + 'title' => 'Cancelled booking', + 'score' => 99, + 'payload' => [ + 'updated_at' => '2026-03-12 12:00:00', + 'status' => 'cancelled', + ], + ], + [ + 'entity_type' => 'bookings', + 'entity_id' => '501', + 'title' => 'Active booking', + 'score' => 80, + 'payload' => [ + 'updated_at' => '2026-03-11 12:00:00', + 'status' => 'active', + ], + ], + ['entity_type' => 'bookings', 'entity_id' => '502', 'title' => 'B502', 'score' => 79], + ['entity_type' => 'bookings', 'entity_id' => '503', 'title' => 'B503', 'score' => 78], + ['entity_type' => 'bookings', 'entity_id' => '504', 'title' => 'B504', 'score' => 77], + ]]); + + $result = $service->search([ + 'query' => 'booking', + 'allowed_types' => ['bookings'], + 'include_associations' => false, + ]); + + expect($parser->calls)->toBe(0); + expect($result['results'][0]['entity_id'])->toBe('501'); + expect($result['results'][1]['entity_id'])->not->toBe('500'); +}); + +it('heavily demotes configured low-priority entity types in ranking', function (): void { + $parser = new FakeSystemSearchIntentParser(); + $service = new TestableSystemSearchService($parser, [[ + [ + 'entity_type' => 'xlvask_customers', + 'entity_id' => '700', + 'title' => 'XLVask customer', + 'score' => 99, + 'payload' => ['updated_at' => '2026-03-12 10:00:00'], + ], + [ + 'entity_type' => 'xlvask_usage_logs', + 'entity_id' => '701', + 'title' => 'XLVask usage log', + 'score' => 98, + 'payload' => ['updated_at' => '2026-03-12 11:00:00'], + ], + [ + 'entity_type' => 'customer_discounts', + 'entity_id' => '702', + 'title' => 'Customer discount', + 'score' => 97, + 'payload' => ['updated_at' => '2026-03-12 12:00:00'], + ], + [ + 'entity_type' => 'department_selfserve_vehicle_conditions', + 'entity_id' => '703', + 'title' => 'Vehicle condition', + 'score' => 96, + 'payload' => ['updated_at' => '2026-03-12 13:00:00'], + ], + [ + 'entity_type' => 'permissions', + 'entity_id' => '704', + 'title' => 'Permission #704', + 'score' => 95, + 'payload' => ['updated_at' => '2026-03-12 14:00:00'], + ], + [ + 'entity_type' => 'branding', + 'entity_id' => '705', + 'title' => 'Branding #705', + 'score' => 94, + 'payload' => ['updated_at' => '2026-03-12 15:00:00'], + ], + [ + 'entity_type' => 'order_items', + 'entity_id' => '706', + 'title' => 'Order item #706', + 'score' => 93, + 'payload' => ['updated_at' => '2026-03-12 16:00:00'], + ], + [ + 'entity_type' => 'module_config', + 'entity_id' => '707', + 'title' => 'Module config #707', + 'score' => 92, + 'payload' => ['updated_at' => '2026-03-12 17:00:00'], + ], + [ + 'entity_type' => 'xlvask_vehicle_types', + 'entity_id' => '710', + 'title' => 'XLVask vehicle type', + 'score' => 91, + 'payload' => ['updated_at' => '2026-03-12 18:00:00'], + ], + [ + 'entity_type' => 'motorapi_lookups', + 'entity_id' => '711', + 'title' => 'MotorAPI lookup', + 'score' => 90, + 'payload' => ['updated_at' => '2026-03-12 19:00:00'], + ], + [ + 'entity_type' => 'orders', + 'entity_id' => '708', + 'title' => 'Order #708', + 'score' => 76, + 'payload' => ['updated_at' => '2026-03-01 00:00:00'], + ], + [ + 'entity_type' => 'customers', + 'entity_id' => '709', + 'title' => 'Customer #709', + 'score' => 74, + 'payload' => ['updated_at' => '2026-03-01 00:00:00'], + ], + ]]); + + $result = $service->search([ + 'query' => '12345', + 'allowed_types' => [ + 'orders', + 'customers', + 'xlvask_customers', + 'xlvask_usage_logs', + 'customer_discounts', + 'department_selfserve_vehicle_conditions', + 'permissions', + 'branding', + 'order_items', + 'module_config', + 'xlvask_vehicle_types', + 'motorapi_lookups', + ], + 'include_associations' => false, + ]); + + $types = array_map(static fn(array $row): string => (string)$row['entity_type'], $result['results']); + expect($parser->calls)->toBe(0); + expect($types[0])->toBe('orders'); + expect($types[1])->toBe('customers'); + expect(array_slice($types, 2, 10))->toBe([ + 'xlvask_customers', + 'xlvask_usage_logs', + 'customer_discounts', + 'department_selfserve_vehicle_conditions', + 'permissions', + 'branding', + 'order_items', + 'module_config', + 'xlvask_vehicle_types', + 'motorapi_lookups', + ]); +});