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:
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user