Add system_search_document_index class to manage document indexing for system search with table creation, entity-specific document builders, and index refresh logic.

This commit is contained in:
Jeppe Bundgaard
2026-03-13 13:58:48 +01:00
parent 1e1576fe87
commit 78a8dd35b9
11 changed files with 3113 additions and 182 deletions
@@ -11,6 +11,7 @@ class system_search_cache
public const INTENT_PREFIX = self::PREFIX . 'intent:';
public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables';
public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request';
public const TABLE_VERSION_PREFIX = self::PREFIX . 'table_version:';
/**
* Optional runtime adapter for tests.
*/
@@ -78,6 +79,7 @@ class system_search_cache
if ($table === '') {
return;
}
self::bumpTableVersion($table);
$tables = self::redisGetArray(self::DIRTY_TABLES_KEY);
if (!in_array($table, $tables, true)) {
$tables[] = $table;
@@ -85,8 +87,6 @@ class system_search_cache
// Keep dirty markers briefly in case cron is delayed.
self::redisExpire(self::DIRTY_TABLES_KEY, 3600);
}
// Query cache depends on mutable data and must be invalidated immediately.
self::clearQueryCaches();
}
public static function consumeDirtyTables(): array
@@ -96,6 +96,56 @@ class system_search_cache
return $tables;
}
public static function peekDirtyTables(): array
{
return self::redisGetArray(self::DIRTY_TABLES_KEY);
}
public static function bumpTableVersion(string $table): int
{
$table = trim($table, " `\t\n\r\0\x0B");
if ($table === '') {
return 0;
}
$key = self::TABLE_VERSION_PREFIX . $table;
try {
$client = self::redisClient();
if ($client === null) {
return 0;
}
if (method_exists($client, 'incr')) {
return (int)$client->incr($key);
}
$current = self::redisGet($key);
$next = max(1, (int)$current + 1);
self::redisSet($key, (string)$next);
return $next;
} catch (Throwable) {
return 0;
}
}
/**
* @param array<int, string> $tables
*/
public static function tableVersionFingerprint(array $tables): string
{
$versions = [];
foreach ($tables as $table) {
if (!is_string($table)) {
continue;
}
$normalized = trim($table, " `\t\n\r\0\x0B");
if ($normalized === '') {
continue;
}
$versions[$normalized] = (int)(self::redisGet(self::TABLE_VERSION_PREFIX . $normalized) ?? 0);
}
ksort($versions);
return md5(json_encode($versions, JSON_UNESCAPED_UNICODE));
}
public static function enqueueRebuild(string $scope = 'all', array $types = []): array
{
$payload = [
File diff suppressed because it is too large Load Diff
@@ -35,6 +35,7 @@ class system_search_economic_customer_index
`economic_email` VARCHAR(255) NULL,
`economic_cvr` VARCHAR(64) NULL,
`economic_mobile_phone` VARCHAR(64) NULL,
`economic_barred` TINYINT(1) NULL,
`search_text` TEXT NULL,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`customer_number`),
@@ -45,9 +46,74 @@ class system_search_economic_customer_index
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
$db->query($sql);
self::ensureColumn(
'economic_barred',
"ALTER TABLE `" . self::TABLE . "` ADD COLUMN `economic_barred` TINYINT(1) NULL AFTER `economic_mobile_phone`"
);
self::$initialized = true;
}
/**
* @param array<int, int> $customerNumbers
* @return array<int, array<string, mixed>>
*/
public static function fetchContexts(array $customerNumbers): array
{
self::ensureTable();
$normalized = array_values(array_unique(array_filter(
array_map('intval', $customerNumbers),
static fn(int $value): bool => $value > 0
)));
if (empty($normalized)) {
return [];
}
global $db;
if (!is_object($db) || !method_exists($db, 'query')) {
return [];
}
$result = $db->query(
"SELECT `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`, `economic_barred`"
. " FROM `" . self::TABLE . "`"
. " WHERE `customer_number` IN (" . implode(',', $normalized) . ")"
);
if (!($result instanceof \mysqli_result)) {
return [];
}
$contexts = [];
while ($row = $result->fetch_assoc()) {
$customerNumber = (int)($row['customer_number'] ?? 0);
if ($customerNumber <= 0) {
continue;
}
$barred = self::toNullableBool($row['economic_barred'] ?? null);
$contexts[$customerNumber] = [
'customer_number' => $customerNumber,
'user_id' => self::toNullableInt($row['user_id'] ?? null),
'name' => self::toNullableString($row['economic_name'] ?? null)
?? self::toNullableString($row['local_display_name'] ?? null),
'barred' => $barred,
'status' => self::barredStatus($barred),
'email' => self::toNullableString($row['economic_email'] ?? null)
?? self::toNullableString($row['local_email'] ?? null),
'phone' => self::toNullableString($row['economic_mobile_phone'] ?? null)
?? self::toNullableString($row['local_phone'] ?? null),
'cvr' => self::toNullableString($row['economic_cvr'] ?? null),
'address' => self::toNullableString($row['economic_address'] ?? null),
'city' => self::toNullableString($row['economic_city'] ?? null),
'zip' => self::toNullableString($row['economic_zip'] ?? null),
];
}
return $contexts;
}
/**
* Rebuild local e-conomic customer index from local users + cached/live e-conomic snapshots.
*
@@ -122,6 +188,7 @@ class system_search_economic_customer_index
$economicEmail = self::toNullableString($economic['email'] ?? null);
$economicCvr = self::toNullableString($economic['corporateIdentificationNumber'] ?? null);
$economicMobilePhone = self::toNullableString($economic['mobilePhone'] ?? null);
$economicBarred = self::toNullableBool($economic['barred'] ?? null);
$searchText = trim(implode(' ', array_values(array_filter([
$customerNumber > 0 ? (string)$customerNumber : null,
@@ -153,6 +220,7 @@ class system_search_economic_customer_index
`economic_email`,
`economic_cvr`,
`economic_mobile_phone`,
`economic_barred`,
`search_text`
) VALUES (
" . (int)$customerNumber . ",
@@ -167,6 +235,7 @@ class system_search_economic_customer_index
" . self::sqlNullableString($economicEmail) . ",
" . self::sqlNullableString($economicCvr) . ",
" . self::sqlNullableString($economicMobilePhone) . ",
" . self::sqlNullableBool($economicBarred) . ",
" . self::sqlNullableString($searchText) . "
) ON DUPLICATE KEY UPDATE
`user_id` = VALUES(`user_id`),
@@ -180,6 +249,7 @@ class system_search_economic_customer_index
`economic_email` = VALUES(`economic_email`),
`economic_cvr` = VALUES(`economic_cvr`),
`economic_mobile_phone` = VALUES(`economic_mobile_phone`),
`economic_barred` = VALUES(`economic_barred`),
`search_text` = VALUES(`search_text`),
`updated_at` = CURRENT_TIMESTAMP";
$db->query($sql);
@@ -223,6 +293,43 @@ class system_search_economic_customer_index
return $string === '' ? null : $string;
}
private static function toNullableInt(mixed $value): ?int
{
if (is_int($value)) {
return $value;
}
if (is_numeric($value) && (string)(int)$value === trim((string)$value)) {
return (int)$value;
}
return null;
}
private static function toNullableBool(mixed $value): ?bool
{
if ($value === null || $value === '') {
return null;
}
if (is_bool($value)) {
return $value;
}
if (is_int($value)) {
return $value !== 0;
}
if (is_string($value)) {
$normalized = trim(mb_strtolower($value));
if ($normalized === '') {
return null;
}
if (in_array($normalized, ['1', 'true', 'yes'], true)) {
return true;
}
if (in_array($normalized, ['0', 'false', 'no'], true)) {
return false;
}
}
return null;
}
private static function sqlNullableString(?string $value): string
{
global $db;
@@ -232,6 +339,38 @@ class system_search_economic_customer_index
return "'" . $db->escape_string($value) . "'";
}
private static function sqlNullableBool(?bool $value): string
{
if ($value === null) {
return 'NULL';
}
return $value ? '1' : '0';
}
private static function ensureColumn(string $column, string $alterSql): void
{
global $db;
if (!is_object($db) || !method_exists($db, 'query')) {
return;
}
$result = $db->query(
"SHOW COLUMNS FROM `" . self::TABLE . "` LIKE '" . $db->escape_string($column) . "'"
);
if ($result instanceof \mysqli_result && $result->num_rows === 0) {
$db->query($alterSql);
}
}
private static function barredStatus(?bool $barred): string
{
return match ($barred) {
true => 'barred',
false => 'active',
default => 'unknown',
};
}
private static function safeAffectedRows(): int
{
global $db;
@@ -78,7 +78,11 @@ class system_search_openai_intent_parser implements system_search_intent_parser_
{
$query = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[email]', $query) ?? $query;
$query = preg_replace('/\b\d{8}\b/', '[cvr]', $query) ?? $query;
$query = preg_replace('/\b[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\b/i', '[uuid]', $query) ?? $query;
$query = preg_replace('/\+?\d[\d\s\-]{6,}\d/', '[phone]', $query) ?? $query;
$query = preg_replace('/\b(order|invoice|booking|customer|kunde|faktura)\s*[#:\-]?\s*\d{4,}\b/iu', '$1 [id]', $query) ?? $query;
$query = preg_replace('/\b(reg(?:istration)?|plate|license plate|nummerplade)\s*[#:\-]?\s*[a-z0-9\-]{4,10}\b/iu', '$1 [plate]', $query) ?? $query;
$query = preg_replace('/\b[a-z]{2}\s?\d{5}\b/iu', '[plate]', $query) ?? $query;
return $query;
}
@@ -0,0 +1,277 @@
<?php
namespace classes;
class system_search_registry
{
/**
* @return array<string, array<string, mixed>>
*/
public static function genericEntityConfigs(): array
{
return [
'bookings' => [
'table' => 'bookings',
'customer_field' => 'customer_number',
'department_field' => 'department',
'search_fields' => ['id', 'customer_number', 'department', 'status', 'reference', 'notes', 'reg', 'reg_1', 'plate'],
],
'bookings_new' => [
'table' => 'bookings_new',
'customer_field' => 'customer_number',
'department_field' => 'department',
'search_fields' => ['id', 'customer_number', 'department', 'status', 'reference', 'notes', 'reg', 'reg_1', 'plate'],
],
'branding' => ['table' => 'branding'],
'categories' => ['table' => 'categories'],
'currency_conversion_rates' => ['table' => 'currency_conversion_rates'],
'customer_codes' => ['table' => 'customer_codes'],
'customer_default_department' => [
'table' => 'customer_default_department',
'customer_field' => 'customer_number',
'department_field' => 'department',
'search_fields' => ['id', 'customer_number', 'department', 'name', 'reference', 'description'],
],
'customer_notes' => [
'table' => 'customer_notes',
'customer_field' => 'customer_id',
'search_fields' => ['id', 'customer_id', 'title', 'note', 'notes', 'description'],
],
'customer_vehicles_addons' => [
'table' => 'customer_vehicles_addons',
'search_fields' => ['id', 'customer_id', 'vehicle_id', 'name', 'reference', 'description', 'type'],
],
'department_categories' => ['table' => 'department_categories', 'department_field' => 'department'],
'department_daily_reports' => [
'table' => 'department_daily_reports',
'department_field' => 'department_id',
'search_fields' => ['id', 'department_id', 'title', 'description', 'notes', 'status'],
],
'department_gates' => ['table' => 'department_gates', 'department_field' => 'department'],
'department_goals' => ['table' => 'goals'],
'department_lanes' => ['table' => 'department_lanes', 'department_field' => 'department'],
'department_notification_sms' => ['table' => 'department_notification_sms', 'department_field' => 'department_id'],
'department_relays' => ['table' => 'department_relays', 'department_field' => 'department'],
'department_selfserve_condition_rules' => ['table' => 'department_selfserve_condition_rules'],
'department_selfserve_conditions' => ['table' => 'department_selfserve_conditions', 'department_field' => 'department'],
'department_selfserve_questions' => ['table' => 'department_selfserve_questions', 'department_field' => 'department'],
'department_selfserve_tasks' => [
'table' => 'department_selfserve_tasks',
'department_field' => 'department',
'search_fields' => ['id', 'department', 'lane', 'product', 'task', 'description'],
'title_fields' => ['task', 'description', 'id'],
'description_fields' => ['description', 'department', 'lane', 'product'],
],
'department_selfserve_vehicle_conditions' => ['table' => 'department_selfserve_vehicle_conditions', 'customer_field' => 'customer_id', 'department_field' => 'department'],
'department_time_bookings_entries' => ['table' => 'department_time_bookings_entries', 'department_field' => 'department'],
'department_time_bookings_opening_hours' => ['table' => 'department_time_bookings_opening_hours', 'department_field' => 'department'],
'department_time_bookings_types' => ['table' => 'department_time_bookings_types', 'department_field' => 'department'],
'department_variables' => ['table' => 'department_variables'],
'fxratesapi_conversion_rates' => ['table' => 'fxratesapi_conversion_rates'],
'module_action_logs' => [
'table' => 'module_usage_logs',
'search_fields' => ['id', 'module', 'action', 'message', 'customer_number', 'customer_id'],
'title_fields' => ['action', 'module', 'id'],
'description_fields' => ['message', 'module'],
],
'motorapi_lookups' => [
'table' => 'motorapi_lookups',
'search_fields' => ['id', 'reg', 'plate', 'reference', 'message', 'status'],
],
'notifications' => [
'table' => 'notifications',
'customer_field' => 'customer_number',
'search_fields' => ['id', 'customer_number', 'customer_id', 'title', 'message', 'type', 'status'],
'title_fields' => ['title', 'type', 'id'],
'description_fields' => ['message', 'status'],
],
'order_bookings' => [
'table' => 'order_bookings',
'customer_field' => 'customer_number',
'department_field' => 'department',
'search_fields' => ['id', 'order_id', 'customer_number', 'department', 'status', 'reference', 'notes'],
],
'plate_scanners' => ['table' => 'plate_scanners', 'department_field' => 'department_id'],
'plate_scans' => [
'table' => 'plate_scans',
'search_fields' => ['id', 'plate', 'number_plate', 'reg', 'status', 'message'],
],
'product_options' => ['table' => 'products_options', 'search_fields' => ['id', 'product_id', 'name', 'description', 'type', 'reference']],
'products' => [
'table' => 'products',
'search_fields' => ['id', 'name', 'description', 'product_number', 'reference'],
'title_fields' => ['name', 'reference', 'id'],
'description_fields' => ['description', 'product_number'],
],
'users' => [
'table' => 'users',
'customer_field' => 'customer_number',
'search_fields' => ['id', 'customer_number', 'display_name', 'email', 'phone', 'username', 'role'],
'title_fields' => ['display_name', 'email', 'customer_number', 'id'],
'description_fields' => ['email', 'phone', 'role'],
],
'stripe_module_customers' => [
'table' => 'stripe_module_customers',
'customer_field' => 'customer_id',
'search_fields' => ['id', 'customer_id', 'name', 'email', 'reference', 'status'],
],
'stripe_module_orders' => [
'table' => 'stripe_module_orders',
'customer_field' => 'customer_id',
'exclude_columns' => ['url'],
'search_fields' => ['id', 'customer_id', 'reference', 'status', 'payment_intent_id'],
],
'stripe_payment_intents' => [
'table' => 'stripe_payment_intents',
'exclude_columns' => ['client_secret', 'data'],
'search_fields' => ['id', 'customer_id', 'status', 'reference', 'payment_method'],
],
'subuser_grants' => [
'table' => 'subuser_grants',
'customer_field' => 'billing_customer_number',
'search_fields' => ['id', 'subuser', 'billing_customer_number', 'name', 'description', 'reference'],
],
'xlvask_customers' => [
'table' => 'xlvask_customers',
'customer_field' => 'externId',
'customer_field_mode' => 'digits_only',
],
'xlvask_potential_order_matches' => ['table' => 'xlvask_potential_order_matches', 'customer_field' => 'customer_number', 'department_field' => 'department'],
'xlvask_usage_log_wash_items' => ['table' => 'xlvask_usage_log_wash_items'],
'xlvask_usage_logs' => ['table' => 'xlvask_usage_logs'],
'xlvask_vehicle_types' => ['table' => 'xlvask_vehicle_types'],
'xlvask_vehicles' => ['table' => 'xlvask_vehicles'],
];
}
/**
* @return array<int, string>
*/
public static function allEntityTypes(): array
{
return array_values(array_unique([
'objects',
'module_config',
'orders',
'order_items',
'customers',
'employees',
'subusers',
'customer_discounts',
'customer_fixed_prices',
'departments',
'permissions',
'roles',
'invoices',
'vehicles',
...array_keys(self::genericEntityConfigs()),
]));
}
/**
* @return array<int, string>
*/
public static function indexedEntityTypes(): array
{
return array_values(array_diff(self::allEntityTypes(), ['permissions', 'subusers']));
}
/**
* @return array<int, string>
*/
public static function sourceTablesForEntityType(string $entityType): array
{
$entityType = trim(mb_strtolower($entityType));
$manual = [
'objects' => ['object_attachments', 'orders', 'department_selfserve_tasks'],
'module_config' => ['module_config'],
'orders' => ['orders'],
'order_items' => ['order_items', 'orders'],
'customers' => ['users', system_search_economic_customer_index::TABLE],
'employees' => ['users', 'groups_permissions'],
'subusers' => ['subusers', 'subuser_grants'],
'customer_discounts' => ['price_overrides', 'users', system_search_economic_customer_index::TABLE],
'customer_fixed_prices' => ['customer_fixed_pricing', system_search_economic_customer_index::TABLE],
'departments' => ['departments'],
'permissions' => [],
'roles' => ['groups'],
'invoices' => ['collected_order_invoices'],
'vehicles' => ['customer_vehicles'],
];
if (isset($manual[$entityType])) {
return $manual[$entityType];
}
$config = self::genericEntityConfigs()[$entityType] ?? null;
if (!is_array($config)) {
return [];
}
$table = trim((string)($config['table'] ?? ''));
return $table === '' ? [] : [$table];
}
/**
* @param array<int, string> $tables
* @return array<int, string>
*/
public static function entityTypesForDirtyTables(array $tables): array
{
$normalizedTables = array_values(array_unique(array_filter(array_map(
static fn($table) => is_string($table) ? trim($table, " `\t\n\r\0\x0B") : '',
$tables
))));
if (empty($normalizedTables)) {
return [];
}
$types = [];
foreach (self::indexedEntityTypes() as $entityType) {
$sourceTables = self::sourceTablesForEntityType($entityType);
if (!empty(array_intersect($normalizedTables, $sourceTables))) {
$types[] = $entityType;
}
}
return array_values(array_unique($types));
}
/**
* @return array<string, array<int, string>>
*/
public static function taxonomyAliases(): array
{
return [
'customers' => ['customer', 'account', 'company', 'kunde'],
'orders' => ['order', 'work order'],
'order_items' => ['order item', 'line item'],
'invoices' => ['invoice', 'billing'],
'vehicles' => ['vehicle', 'truck', 'plate'],
'employees' => ['employee', 'staff'],
'subusers' => ['subuser', 'driver'],
'customer_discounts' => ['discount', 'price override', 'rabat'],
'customer_fixed_prices' => ['fixed price', 'monthly agreement'],
'departments' => ['department', 'location'],
'permissions' => ['permission', 'acl'],
'roles' => ['role', 'group'],
'module_config' => ['module config', 'setting', 'configuration'],
'objects' => ['attachment', 'object'],
'bookings' => ['booking', 'wash booking'],
'bookings_new' => ['new booking', 'booking queue'],
'customer_notes' => ['customer note', 'note'],
'order_bookings' => ['order booking', 'scheduled order'],
'products' => ['product', 'service'],
'product_options' => ['product option', 'addon', 'add on'],
'plate_scans' => ['plate scan', 'license plate scan'],
'plate_scanners' => ['plate scanner', 'license plate scanner'],
'notifications' => ['notification', 'alert'],
'users' => ['user', 'account user'],
'module_action_logs' => ['module log', 'action log'],
'motorapi_lookups' => ['motorapi lookup', 'plate lookup'],
'xlvask_customers' => ['xlvask customer'],
'xlvask_vehicles' => ['xlvask vehicle'],
'xlvask_usage_logs' => ['xlvask usage log'],
'department_daily_reports' => ['department daily report', 'daily report'],
];
}
}
File diff suppressed because it is too large Load Diff
+31 -3
View File
@@ -4,7 +4,9 @@
use classes\backup_store;
use classes\economic;
use classes\system_search_cache;
use classes\system_search_document_index;
use classes\system_search_economic_customer_index;
use classes\system_search_registry;
use classes\xlvask;
use classes\slack as Slack;
use classes\email as Email;
@@ -146,6 +148,14 @@ function SyncUserEconomicCustomerDetails(): void
$users_o->clearAllUsersEconomicCustomerDetailsFromCache();
$users_o->syncAllUsersEconomicCustomerDetails();
$stats = system_search_economic_customer_index::refreshIndex(false);
system_search_document_index::refreshIndex([
'customers',
'customer_discounts',
'customer_fixed_prices',
'employees',
'users',
]);
system_search_cache::bumpTableVersion(system_search_economic_customer_index::TABLE);
echo "[" . date('Y-m-d H:i:s') . "][CRON] Refreshed e-conomic customer snapshots and search index. Upserted: "
. (int)($stats['upserted'] ?? 0) . "\n";
} catch (Throwable $e) {
@@ -157,6 +167,14 @@ function SyncSystemSearchEconomicCustomerIndex(): void
{
try {
$stats = system_search_economic_customer_index::refreshIndex(false);
system_search_document_index::refreshIndex([
'customers',
'customer_discounts',
'customer_fixed_prices',
'employees',
'users',
]);
system_search_cache::bumpTableVersion(system_search_economic_customer_index::TABLE);
echo "[" . date('Y-m-d H:i:s') . "][CRON] Synced system search e-conomic customer index. Processed: "
. (int)($stats['processed'] ?? 0) . ", upserted: " . (int)($stats['upserted'] ?? 0)
. ", deleted: " . (int)($stats['deleted'] ?? 0) . "\n";
@@ -204,17 +222,27 @@ function SystemSearchCacheMaintenanceCron(): void
if ($rebuildRequest !== null) {
system_search_cache::clearQueryCaches();
system_search_cache::clearIntentCaches();
system_search_economic_customer_index::refreshIndex(false);
$scope = (string)($rebuildRequest['scope'] ?? 'all');
$types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? []))));
if ($scope === 'types' && !empty($types)) {
system_search_document_index::refreshIndex($types);
} else {
system_search_economic_customer_index::refreshIndex(false);
system_search_document_index::refreshIndex();
}
echo "[" . date('Y-m-d H:i:s') . "][CRON] System search cache rebuild handled. Scope: " . ($rebuildRequest['scope'] ?? 'all') . "\n";
return;
}
if (!empty($dirtyTables)) {
system_search_cache::clearQueryCaches();
if (in_array('users', $dirtyTables, true)) {
system_search_economic_customer_index::refreshIndex(false);
}
echo "[" . date('Y-m-d H:i:s') . "][CRON] System search query cache invalidated for dirty tables: " . implode(', ', $dirtyTables) . "\n";
$typesToRefresh = system_search_registry::entityTypesForDirtyTables($dirtyTables);
if (!empty($typesToRefresh)) {
system_search_document_index::refreshIndex($typesToRefresh);
}
echo "[" . date('Y-m-d H:i:s') . "][CRON] System search maintenance handled dirty tables: " . implode(', ', $dirtyTables) . "\n";
}
} catch (Throwable $e) {
warn('SystemSearchCacheMaintenanceCron failed: ' . $e->getMessage());
@@ -144,14 +144,17 @@ it('clears parser cache namespace via clearAll to force a fresh parse', function
expect($calls)->toBe(2);
});
it('invalidates query cache immediately when dirty-table marker is registered', function (): void {
it('bumps per-table cache versions when a dirty-table marker is registered', function (): void {
$hash = md5('test-query');
system_search_cache::setQuery($hash, ['results' => [], 'grouped_results' => [], 'meta' => []], 120);
expect(system_search_cache::getQuery($hash))->not->toBeNull();
$before = system_search_cache::tableVersionFingerprint(['orders']);
system_search_cache::markDirtyTable('orders');
$dirty = system_search_cache::consumeDirtyTables();
$after = system_search_cache::tableVersionFingerprint(['orders']);
expect(system_search_cache::getQuery($hash))->toBeNull();
expect(system_search_cache::getQuery($hash))->not->toBeNull();
expect($dirty)->toContain('orders');
expect($after)->not->toBe($before);
});
@@ -2,6 +2,9 @@
app_require('routes/systemSearchRoute.php');
app_require('interfaces/system_search_intent_parser_i.php');
app_require('classes/system_search_document_index.php');
app_require('classes/system_search_economic_customer_index.php');
app_require('classes/system_search_registry.php');
app_require('classes/system_search_service.php');
use classes\system_search_service;
@@ -78,14 +78,19 @@ afterEach(function (): void {
});
it('redacts obvious sensitive fragments before sending query to intent parser', function (): void {
$query = 'Contact alice@example.com at +45 12 34 56 78, cvr 12345678';
$query = 'Contact alice@example.com at +45 12 34 56 78, cvr 12345678, order 987654, reg AB12345, id 550e8400-e29b-41d4-a716-446655440000';
$redacted = system_search_openai_intent_parser::redactSensitiveQuery($query);
expect($redacted)->toContain('[email]');
expect($redacted)->toContain('[phone]');
expect($redacted)->toContain('[cvr]');
expect($redacted)->toContain('order [id]');
expect($redacted)->toContain('[plate]');
expect($redacted)->toContain('[uuid]');
expect($redacted)->not->toContain('alice@example.com');
expect($redacted)->not->toContain('12345678');
expect($redacted)->not->toContain('987654');
expect($redacted)->not->toContain('AB12345');
});
it('builds payload with redacted query and parses strict JSON output', function (): void {
@@ -2,10 +2,14 @@
app_require('interfaces/system_search_intent_parser_i.php');
app_require('classes/system_search_cache.php');
app_require('classes/system_search_document_index.php');
app_require('classes/system_search_economic_customer_index.php');
app_require('classes/system_search_openai_intent_parser.php');
app_require('classes/system_search_registry.php');
app_require('classes/system_search_service.php');
use classes\system_search_cache;
use classes\system_search_economic_customer_index;
use classes\system_search_service;
use interfaces\system_search_intent_parser_i;
@@ -145,6 +149,38 @@ if (!class_exists('TestableSystemSearchService')) {
}
}
if (!class_exists('CustomerContextAwareTestableSystemSearchService')) {
class CustomerContextAwareTestableSystemSearchService extends TestableSystemSearchService
{
/**
* @var array<int, array<string, mixed>>
*/
public array $customerContexts = [];
protected function loadCustomerContexts(array $customerNumbers): array
{
$contexts = [];
foreach ($customerNumbers as $customerNumber) {
$normalized = (int)$customerNumber;
if ($normalized <= 0 || !isset($this->customerContexts[$normalized])) {
continue;
}
$contexts[$normalized] = $this->customerContexts[$normalized];
}
return $contexts;
}
}
}
if (!function_exists('system_search_service_invoke_private')) {
function system_search_service_invoke_private(object $instance, string $method, array $args = []): mixed
{
$reflection = new ReflectionMethod($instance, $method);
$reflection->setAccessible(true);
return $reflection->invokeArgs($instance, $args);
}
}
beforeEach(function (): void {
system_search_cache::setAdapterForTests(null);
});
@@ -725,3 +761,159 @@ it('heavily demotes configured low-priority entity types in ranking', function (
'motorapi_lookups',
]);
});
it('tokenizes unicode names without stripping non ascii letters', function (): void {
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$tokens = system_search_service_invoke_private($service, 'tokenize', ['Møller Århus']);
expect($tokens)->toContain('møller');
expect($tokens)->toContain('århus');
expect($tokens)->not->toContain('ller');
expect($tokens)->not->toContain('rhus');
});
it('does not treat explicit identifier queries as intent driven', function (): void {
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$looksIntentDriven = system_search_service_invoke_private(
$service,
'queryLooksIntentDriven',
['order 123456 for acme', ['order', '123456', 'for', 'acme']]
);
expect($looksIntentDriven)->toBeFalse();
});
it('requires broader term coverage for multi word scoring', function (): void {
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$narrowScore = system_search_service_invoke_private(
$service,
'scoreRow',
[['title' => 'Acme Corp', 'description' => ''], ['title' => 4, 'description' => 2], ['acme', 'overdue', 'invoice']]
);
$broadScore = system_search_service_invoke_private(
$service,
'scoreRow',
[['title' => 'Acme Corp', 'description' => 'Overdue invoice'], ['title' => 4, 'description' => 2], ['acme', 'overdue', 'invoice']]
);
expect($narrowScore)->toBe(0);
expect($broadScore)->toBeGreaterThan(0);
});
it('falls back to invoice date ranges when invoice names are missing', function (): void {
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$fullRangeTitle = system_search_service_invoke_private(
$service,
'invoiceResultTitle',
[null, '2026-02-01 00:00:01', '2026-02-28 23:59:59', 42]
);
$fromOnlyTitle = system_search_service_invoke_private(
$service,
'invoiceResultTitle',
['', '2026-02-01 00:00:01', null, 43]
);
$fallbackTitle = system_search_service_invoke_private(
$service,
'invoiceResultTitle',
[null, null, null, 44]
);
expect($fullRangeTitle)->toBe('2026-02-01 - 2026-02-28');
expect($fromOnlyTitle)->toBe('2026-02-01');
expect($fallbackTitle)->toBe('Invoice collection #44');
});
it('derives xlvask customer numbers only from digits-only extern ids', function (): void {
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$digitsOnly = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['12345679', 'digits_only']);
$uuidLike = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['09ed15d4-5a12-4d23-beac-4065174a74eb', 'digits_only']);
$mixed = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['12345-A', 'digits_only']);
$blank = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', [' ', 'digits_only']);
expect($digitsOnly)->toBe(12345679);
expect($uuidLike)->toBeNull();
expect($mixed)->toBeNull();
expect($blank)->toBeNull();
});
it('replaces unnamed user titles with the customer context name', function (): void {
$service = new CustomerContextAwareTestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$service->customerContexts = [
777 => [
'customer_number' => 777,
'name' => 'Acme Transport',
'barred' => false,
'status' => 'active',
],
];
$result = system_search_service_invoke_private($service, 'decorateSearchResultWithCustomerContext', [[
'entity_type' => 'users',
'entity_id' => '55',
'title' => 'unnamed',
'description' => '',
'customer_number' => 777,
'payload' => [
'id' => 55,
'display_name' => 'unnamed',
],
]]);
expect($result['title'])->toBe('Acme Transport');
expect($result['customer_name'])->toBe('Acme Transport');
});
it('enriches object attachment results with associated customer context', function (): void {
$service = new CustomerContextAwareTestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$service->customerContexts = [
777 => [
'customer_number' => 777,
'user_id' => 55,
'name' => 'Acme Transport',
'barred' => true,
'status' => 'barred',
'email' => 'dispatch@acme.test',
'phone' => '40112233',
'cvr' => '12345678',
'address' => 'Road 1',
'city' => 'Aarhus',
'zip' => '8000',
],
];
$result = system_search_service_invoke_private($service, 'buildObjectSearchResult', [[
'id' => 88,
'object_type' => 'orders',
'object_id' => 501,
'content' => json_encode(['other' => 'wash_certificate.pdf'], JSON_UNESCAPED_UNICODE),
'customer_number' => 777,
'department_id' => 12,
'order_reference' => 'REF-501',
'customer_name' => 'Acme Transport',
'updated_at' => '2026-03-11 12:00:00',
'created_at' => '2026-03-10 12:00:00',
], ['acme', '777'], 9]);
expect($result['entity_type'])->toBe('objects');
expect($result['customer_number'])->toBe(777);
expect($result['customer_name'])->toBe('Acme Transport');
expect($result['customer_barred'])->toBeTrue();
expect($result['customer_status'])->toBe('barred');
expect($result['description'])->toBe('REF-501 / Acme Transport');
expect($result['payload']['linked_entity_type'])->toBe('orders');
expect($result['payload']['order_reference'])->toBe('REF-501');
expect($result['payload']['customer_context']['cvr'])->toBe('12345678');
});
it('includes the economic customer index in cache dependencies for customer scoped results', function (): void {
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$tables = system_search_service_invoke_private($service, 'relevantSourceTables', [['objects', 'orders', 'vehicles']]);
expect($tables)->toContain(system_search_economic_customer_index::TABLE);
});