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.
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use objects\users_o;
|
||||
use Throwable;
|
||||
|
||||
class system_search_economic_customer_index
|
||||
{
|
||||
public const TABLE = 'system_search_economic_customer_index';
|
||||
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTable(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
if (!is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sql = "CREATE TABLE IF NOT EXISTS `" . self::TABLE . "` (
|
||||
`customer_number` INT NOT NULL,
|
||||
`user_id` INT NULL,
|
||||
`local_display_name` VARCHAR(255) NULL,
|
||||
`local_email` VARCHAR(255) NULL,
|
||||
`local_phone` VARCHAR(64) NULL,
|
||||
`economic_name` VARCHAR(255) NULL,
|
||||
`economic_address` VARCHAR(255) NULL,
|
||||
`economic_city` VARCHAR(255) NULL,
|
||||
`economic_zip` VARCHAR(64) NULL,
|
||||
`economic_email` VARCHAR(255) NULL,
|
||||
`economic_cvr` VARCHAR(64) NULL,
|
||||
`economic_mobile_phone` VARCHAR(64) NULL,
|
||||
`search_text` TEXT NULL,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`customer_number`),
|
||||
INDEX `idx_system_search_econ_customer_user` (`user_id`),
|
||||
INDEX `idx_system_search_econ_customer_name` (`economic_name`),
|
||||
INDEX `idx_system_search_econ_customer_email` (`economic_email`),
|
||||
INDEX `idx_system_search_econ_customer_cvr` (`economic_cvr`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
|
||||
|
||||
$db->query($sql);
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild local e-conomic customer index from local users + cached/live e-conomic snapshots.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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<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>
|
||||
@@ -1490,6 +1803,52 @@ class system_search_service
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
it('wires local e-conomic customer index into customer-related search entities', function (): void {
|
||||
$serviceContent = file_get_contents(app_path('classes/system_search_service.php'));
|
||||
|
||||
expect($serviceContent)->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)');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user