From 78a8dd35b9d550b7269ca86f3b680f9bbab37182 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Fri, 13 Mar 2026 13:58:48 +0100 Subject: [PATCH] Add `system_search_document_index` class to manage document indexing for system search with table creation, entity-specific document builders, and index refresh logic. --- .../nginx/app/classes/system_search_cache.php | 54 +- .../classes/system_search_document_index.php | 1363 +++++++++++++++++ .../system_search_economic_customer_index.php | 139 ++ .../system_search_openai_intent_parser.php | 4 + .../app/classes/system_search_registry.php | 277 ++++ .../app/classes/system_search_service.php | 1215 ++++++++++++--- services/nginx/app/cron/Cron.php | 34 +- .../SystemSearchCacheIntegrationTest.php | 7 +- .../SystemSearchEntityTypeCoverageTest.php | 3 + .../SystemSearchOpenAiIntentParserTest.php | 7 +- .../SystemSearchServiceIntentFlowTest.php | 192 +++ 11 files changed, 3113 insertions(+), 182 deletions(-) create mode 100644 services/nginx/app/classes/system_search_document_index.php create mode 100644 services/nginx/app/classes/system_search_registry.php diff --git a/services/nginx/app/classes/system_search_cache.php b/services/nginx/app/classes/system_search_cache.php index c0f8cd1c..fe524c41 100644 --- a/services/nginx/app/classes/system_search_cache.php +++ b/services/nginx/app/classes/system_search_cache.php @@ -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 $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 = [ diff --git a/services/nginx/app/classes/system_search_document_index.php b/services/nginx/app/classes/system_search_document_index.php new file mode 100644 index 00000000..ef8dee06 --- /dev/null +++ b/services/nginx/app/classes/system_search_document_index.php @@ -0,0 +1,1363 @@ +> + */ + private array $tableColumnsCache = []; + + 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 . "` ( + `entity_type` VARCHAR(64) NOT NULL, + `entity_id` VARCHAR(191) NOT NULL, + `customer_number` INT NULL, + `department_id` INT NULL, + `title` TEXT NULL, + `description` TEXT NULL, + `search_text` MEDIUMTEXT NULL, + `payload_json` LONGTEXT NULL, + `created_at` DATETIME NULL, + `updated_at` DATETIME NULL, + `indexed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`entity_type`, `entity_id`), + INDEX `idx_ssd_customer` (`customer_number`), + INDEX `idx_ssd_department` (`department_id`), + INDEX `idx_ssd_entity` (`entity_type`), + FULLTEXT KEY `ft_ssd_text` (`title`, `description`, `search_text`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; + + try { + $db->query($sql); + self::$initialized = true; + } catch (Throwable) { + // Search should stay available even if index bootstrap fails. + } + } + + /** + * @param array $types + * @return array + */ + public static function refreshIndex(array $types = []): array + { + $instance = new self(); + return $instance->refresh($types); + } + + /** + * @param array $types + * @return array + */ + private function refresh(array $types = []): array + { + self::ensureTable(); + system_search_economic_customer_index::ensureTable(); + + $stats = [ + 'types' => 0, + 'documents' => 0, + 'errors' => 0, + ]; + + $targetTypes = array_values(array_unique(array_filter(array_map( + static fn($type) => is_string($type) ? trim(mb_strtolower($type)) : '', + $types + )))); + if (empty($targetTypes)) { + $targetTypes = system_search_registry::indexedEntityTypes(); + } else { + $targetTypes = array_values(array_intersect(system_search_registry::indexedEntityTypes(), $targetTypes)); + } + + foreach ($targetTypes as $entityType) { + try { + $documents = $this->buildDocumentsForType($entityType); + $this->replaceDocumentsForType($entityType, $documents); + $stats['types']++; + $stats['documents'] += count($documents); + } catch (Throwable) { + $stats['errors']++; + } + } + + return $stats; + } + + /** + * @return array> + */ + private function buildDocumentsForType(string $entityType): array + { + return match ($entityType) { + 'customers' => $this->buildCustomerDocuments(), + 'employees' => $this->buildEmployeeDocuments(), + 'orders' => $this->buildOrderDocuments(), + 'order_items' => $this->buildOrderItemDocuments(), + 'invoices' => $this->buildInvoiceDocuments(), + 'vehicles' => $this->buildVehicleDocuments(), + 'customer_discounts' => $this->buildCustomerDiscountDocuments(), + 'customer_fixed_prices' => $this->buildCustomerFixedPriceDocuments(), + 'departments' => $this->buildSimpleTableDocuments('departments', 'departments', ['id', 'name', 'address', 'zip', 'city'], ['id', 'name', 'address', 'zip', 'city'], ['name', 'id'], ['address', 'city']), + 'roles' => $this->buildSimpleTableDocuments('roles', 'groups', ['id', 'name', 'description'], ['id', 'name', 'description'], ['name', 'id'], ['description']), + 'module_config' => $this->buildModuleConfigDocuments(), + 'objects' => $this->buildObjectAttachmentDocuments(), + default => $this->buildGenericDocuments($entityType), + }; + } + + /** + * @return array> + */ + private function buildCustomerDocuments(): array + { + $fromClause = 'users u'; + $selectFields = [ + 'u.id AS entity_id', + 'u.customer_number', + 'u.display_name', + 'u.email', + 'u.phone', + ...$this->joinTemporalSelectFields('users', 'u'), + ]; + + if ($this->tableExists(system_search_economic_customer_index::TABLE)) { + $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number'; + $selectFields = [ + ...$selectFields, + 'sci.economic_name', + 'sci.economic_address', + 'sci.economic_city', + 'sci.economic_zip', + 'sci.economic_email', + 'sci.economic_cvr', + 'sci.economic_mobile_phone', + 'sci.search_text', + ]; + } + + $rows = $this->fetchRows( + "SELECT " . implode(', ', $selectFields) + . " FROM " . $fromClause + . " WHERE u.customer_number IS NOT NULL AND u.customer_number <> 0" + ); + + $documents = []; + foreach ($rows as $row) { + $title = trim((string)($row['economic_name'] ?? '')); + if ($title === '') { + $title = trim((string)($row['display_name'] ?? '')); + } + if ($title === '') { + $title = 'Customer #' . (string)($row['customer_number'] ?? ''); + } + + $description = trim((string)($row['email'] ?? '')); + if ($description === '') { + $description = trim((string)($row['economic_email'] ?? '')); + } + + $documents[] = $this->makeDocument( + 'customers', + (string)($row['entity_id'] ?? ''), + $title, + $description, + $this->implodeSearchText([ + $row['customer_number'] ?? null, + $row['display_name'] ?? null, + $row['email'] ?? null, + $row['phone'] ?? null, + $row['economic_name'] ?? null, + $row['economic_address'] ?? null, + $row['economic_city'] ?? null, + $row['economic_zip'] ?? null, + $row['economic_email'] ?? null, + $row['economic_cvr'] ?? null, + $row['economic_mobile_phone'] ?? null, + $row['search_text'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + null, + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'display_name' => $row['display_name'] ?? null, + 'email' => $row['email'] ?? null, + 'phone' => $row['phone'] ?? null, + 'economic_name' => $row['economic_name'] ?? null, + 'economic_address' => $row['economic_address'] ?? null, + 'economic_city' => $row['economic_city'] ?? null, + 'economic_zip' => $row['economic_zip'] ?? null, + 'economic_email' => $row['economic_email'] ?? null, + 'economic_cvr' => $row['economic_cvr'] ?? null, + 'economic_mobile_phone' => $row['economic_mobile_phone'] ?? null, + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildEmployeeDocuments(): array + { + $temporalSelect = $this->joinTemporalSelectFields('users', 'u'); + $rows = $this->fetchRows( + "SELECT DISTINCT u.id AS entity_id, u.customer_number, u.display_name, u.email, u.phone" + . (!empty($temporalSelect) ? (', ' . implode(', ', $temporalSelect)) : '') + . " FROM users u" + . " INNER JOIN groups_permissions gp ON gp.group_id = u.group_id" + . " WHERE gp.permission = 'employee_public_data'" + ); + + $documents = []; + foreach ($rows as $row) { + $documents[] = $this->makeDocument( + 'employees', + (string)($row['entity_id'] ?? ''), + (string)(($row['display_name'] ?? '') ?: ('Employee #' . ($row['entity_id'] ?? ''))), + (string)($row['email'] ?? ''), + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['customer_number'] ?? null, + $row['display_name'] ?? null, + $row['email'] ?? null, + $row['phone'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + null, + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'display_name' => $row['display_name'] ?? null, + 'email' => $row['email'] ?? null, + 'phone' => $row['phone'] ?? null, + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildOrderDocuments(): array + { + $rows = $this->fetchRows( + "SELECT id AS entity_id, customer_id AS customer_number, reference, notes, reg_1, reg_2, reg_3, department_id, po, created_at, updated_at" + . " FROM orders WHERE deleted_at IS NULL" + ); + + $documents = []; + foreach ($rows as $row) { + $documents[] = $this->makeDocument( + 'orders', + (string)($row['entity_id'] ?? ''), + 'Order #' . (string)($row['entity_id'] ?? ''), + (string)($row['reference'] ?? ''), + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['customer_number'] ?? null, + $row['reference'] ?? null, + $row['notes'] ?? null, + $row['reg_1'] ?? null, + $row['reg_2'] ?? null, + $row['reg_3'] ?? null, + $row['po'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + $this->toIntOrNull($row['department_id'] ?? null), + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'customer_id' => $this->toIntOrNull($row['customer_number'] ?? null), + 'reference' => $row['reference'] ?? null, + 'notes' => $row['notes'] ?? null, + 'reg_1' => $row['reg_1'] ?? null, + 'reg_2' => $row['reg_2'] ?? null, + 'reg_3' => $row['reg_3'] ?? null, + 'po' => $row['po'] ?? null, + 'department_id' => $this->toIntOrNull($row['department_id'] ?? null), + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildOrderItemDocuments(): array + { + $temporalSelect = $this->joinTemporalSelectFields('order_items', 'oi'); + $rows = $this->fetchRows( + "SELECT oi.id AS entity_id, oi.order_id, oi.product_id, oi.reference, oi.notes, o.customer_id AS customer_number" + . (!empty($temporalSelect) ? (', ' . implode(', ', $temporalSelect)) : '') + . " FROM order_items oi" + . " INNER JOIN orders o ON o.id = oi.order_id" + . " WHERE o.deleted_at IS NULL" + ); + + $documents = []; + foreach ($rows as $row) { + $documents[] = $this->makeDocument( + 'order_items', + (string)($row['entity_id'] ?? ''), + 'Order item #' . (string)($row['entity_id'] ?? ''), + (string)($row['reference'] ?? ''), + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['order_id'] ?? null, + $row['product_id'] ?? null, + $row['reference'] ?? null, + $row['notes'] ?? null, + $row['customer_number'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + null, + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'order_id' => $this->toIntOrNull($row['order_id'] ?? null), + 'product_id' => $this->toIntOrNull($row['product_id'] ?? null), + 'reference' => $row['reference'] ?? null, + 'notes' => $row['notes'] ?? null, + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildInvoiceDocuments(): array + { + $rows = $this->fetchRows( + "SELECT id AS entity_id, customer_number, name, notes, external_id, booked_invoice_id, po_number, created_at, updated_at, closed_at" + . " FROM collected_order_invoices WHERE deleted_at IS NULL" + ); + + $documents = []; + foreach ($rows as $row) { + $title = $this->invoiceDocumentTitle( + $row['name'] ?? null, + $row['created_at'] ?? null, + $row['closed_at'] ?? null, + $row['entity_id'] ?? null + ); + $documents[] = $this->makeDocument( + 'invoices', + (string)($row['entity_id'] ?? ''), + $title, + (string)($row['external_id'] ?? ''), + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['customer_number'] ?? null, + $row['name'] ?? null, + $row['notes'] ?? null, + $row['external_id'] ?? null, + $row['booked_invoice_id'] ?? null, + $row['po_number'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + null, + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'name' => $row['name'] ?? null, + 'external_id' => $row['external_id'] ?? null, + 'booked_invoice_id' => $row['booked_invoice_id'] ?? null, + 'po_number' => $row['po_number'] ?? null, + 'closed_at' => $row['closed_at'] ?? null, + ], + $row + ); + } + + return $documents; + } + + private function invoiceDocumentTitle(mixed $name, mixed $fromDate, mixed $toDate, mixed $invoiceId): string + { + $resolvedName = trim((string)($name ?? '')); + if ($resolvedName !== '') { + return $resolvedName; + } + + $dateRange = $this->invoiceDateRangeLabel($fromDate, $toDate); + if ($dateRange !== '') { + return $dateRange; + } + + return 'Invoice collection #' . (string)$invoiceId; + } + + private function invoiceDateRangeLabel(mixed $fromDate, mixed $toDate): string + { + $fromLabel = $this->invoiceDateLabel($fromDate); + $toLabel = $this->invoiceDateLabel($toDate); + + if ($fromLabel !== '' && $toLabel !== '' && $fromLabel !== $toLabel) { + return $fromLabel . ' - ' . $toLabel; + } + if ($fromLabel !== '') { + return $fromLabel; + } + if ($toLabel !== '') { + return $toLabel; + } + + return ''; + } + + private function invoiceDateLabel(mixed $value): string + { + if ($value === null) { + return ''; + } + + $raw = trim((string)$value); + if ($raw === '' || $raw === '0000-00-00' || $raw === '0000-00-00 00:00:00') { + return ''; + } + + $timestamp = strtotime($raw); + if ($timestamp === false) { + return ''; + } + + return date('Y-m-d', $timestamp); + } + + /** + * @return array> + */ + private function buildVehicleDocuments(): array + { + $rows = $this->fetchRows( + "SELECT id AS entity_id, customer_id AS customer_number, reg, reference, type, created_at, updated_at" + . " FROM customer_vehicles WHERE deleted_at IS NULL" + ); + + $documents = []; + foreach ($rows as $row) { + $documents[] = $this->makeDocument( + 'vehicles', + (string)($row['entity_id'] ?? ''), + (string)(($row['reg'] ?? '') ?: ('Vehicle #' . ($row['entity_id'] ?? ''))), + (string)($row['reference'] ?? ''), + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['customer_number'] ?? null, + $row['reg'] ?? null, + $row['reference'] ?? null, + $row['type'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + null, + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'customer_id' => $this->toIntOrNull($row['customer_number'] ?? null), + 'reg' => $row['reg'] ?? null, + 'reference' => $row['reference'] ?? null, + 'type' => $row['type'] ?? null, + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildCustomerDiscountDocuments(): array + { + $fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id'; + $selectFields = [ + 'po.id AS entity_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'), + ]; + + if ($this->tableExists(system_search_economic_customer_index::TABLE)) { + $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number'; + $selectFields = [ + ...$selectFields, + 'sci.economic_name', + 'sci.economic_address', + 'sci.economic_city', + 'sci.economic_zip', + 'sci.economic_email', + 'sci.economic_cvr', + 'sci.economic_mobile_phone', + 'sci.search_text', + ]; + } + + $rows = $this->fetchRows("SELECT " . implode(', ', $selectFields) . " FROM " . $fromClause); + + $documents = []; + foreach ($rows as $row) { + $customerDisplay = trim((string)($row['economic_name'] ?? '')); + if ($customerDisplay === '') { + $customerDisplay = trim((string)($row['display_name'] ?? '')); + } + + $documents[] = $this->makeDocument( + 'customer_discounts', + (string)($row['entity_id'] ?? ''), + 'Discount #' . (string)($row['entity_id'] ?? ''), + (string)('Customer ' . ($row['customer_number'] ?? '') . ' / ' . $customerDisplay), + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['customer_number'] ?? null, + $row['display_name'] ?? null, + $row['economic_name'] ?? null, + $row['economic_address'] ?? null, + $row['economic_city'] ?? null, + $row['economic_zip'] ?? null, + $row['economic_email'] ?? null, + $row['economic_cvr'] ?? null, + $row['economic_mobile_phone'] ?? null, + $row['search_text'] ?? null, + $row['product_or_category_id'] ?? null, + $row['percentage'] ?? null, + $row['user_id'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + null, + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'user_id' => $this->toIntOrNull($row['user_id'] ?? null), + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'product_or_category_id' => $row['product_or_category_id'] ?? null, + 'percentage' => $this->toIntOrNull($row['percentage'] ?? null), + 'economic_name' => $row['economic_name'] ?? null, + 'economic_cvr' => $row['economic_cvr'] ?? null, + 'is_category' => $row['is_category'] ?? null, + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildCustomerFixedPriceDocuments(): array + { + $fromClause = 'customer_fixed_pricing cfp'; + $selectFields = [ + 'cfp.id AS entity_id', + 'cfp.customer_number', + 'cfp.price', + 'cfp.description', + ...$this->joinTemporalSelectFields('customer_fixed_pricing', 'cfp'), + ]; + + if ($this->tableExists(system_search_economic_customer_index::TABLE)) { + $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = cfp.customer_number'; + $selectFields = [ + ...$selectFields, + 'sci.economic_name', + 'sci.economic_address', + 'sci.economic_city', + 'sci.economic_zip', + 'sci.economic_email', + 'sci.economic_cvr', + 'sci.economic_mobile_phone', + 'sci.search_text', + ]; + } + + $rows = $this->fetchRows("SELECT " . implode(', ', $selectFields) . " FROM " . $fromClause); + + $documents = []; + foreach ($rows as $row) { + $description = trim((string)($row['description'] ?? '')); + if ($description === '') { + $description = trim((string)($row['economic_name'] ?? '')); + } + + $documents[] = $this->makeDocument( + 'customer_fixed_prices', + (string)($row['entity_id'] ?? ''), + 'Fixed pricing #' . (string)($row['entity_id'] ?? ''), + $description, + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['customer_number'] ?? null, + $row['price'] ?? null, + $row['description'] ?? null, + $row['economic_name'] ?? null, + $row['economic_address'] ?? null, + $row['economic_city'] ?? null, + $row['economic_zip'] ?? null, + $row['economic_email'] ?? null, + $row['economic_cvr'] ?? null, + $row['economic_mobile_phone'] ?? null, + $row['search_text'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + null, + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'price' => $this->toIntOrNull($row['price'] ?? null), + 'description' => $row['description'] ?? null, + 'economic_name' => $row['economic_name'] ?? null, + 'economic_cvr' => $row['economic_cvr'] ?? null, + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildModuleConfigDocuments(): array + { + $rows = $this->fetchRows( + "SELECT module, variable, type, created_at, updated_at" + . " FROM module_config" + ); + + $documents = []; + foreach ($rows as $row) { + $variable = (string)($row['variable'] ?? ''); + if ($variable === '' || $this->looksSecretVariable($variable)) { + continue; + } + + $module = (string)($row['module'] ?? ''); + $entityId = $module . ':' . $variable; + $documents[] = $this->makeDocument( + 'module_config', + $entityId, + $module . '.' . $variable, + (string)($row['type'] ?? ''), + $this->implodeSearchText([$module, $variable, $row['type'] ?? null]), + null, + null, + [ + 'module' => $module, + 'variable' => $variable, + 'type' => $row['type'] ?? null, + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildObjectAttachmentDocuments(): array + { + $temporalSelect = $this->joinTemporalSelectFields('object_attachments', 'oa'); + $taskSelect = []; + $taskColumns = $this->getColumns('department_selfserve_tasks'); + foreach (['task', 'description', 'department'] as $column) { + if (in_array($column, $taskColumns, true)) { + $taskSelect[] = 'dst.' . $column . ' AS task_' . $column; + } + } + + $taskDepartmentJoin = ''; + if ($this->tableExists('departments') && in_array('department', $taskColumns, true) && in_array('name', $this->getColumns('departments'), true)) { + $taskDepartmentJoin = ' LEFT JOIN departments d ON d.id = dst.department'; + $taskSelect[] = 'd.name AS task_department_name'; + } + + $customerSelect = []; + $customerJoin = ''; + if ($this->tableExists(system_search_economic_customer_index::TABLE)) { + $customerJoin = ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = o.customer_id'; + $customerSelect = [ + 'COALESCE(sci.economic_name, sci.local_display_name) AS customer_name', + 'COALESCE(sci.economic_email, sci.local_email) AS customer_email', + 'COALESCE(sci.economic_mobile_phone, sci.local_phone) AS customer_phone', + 'sci.economic_cvr AS customer_cvr', + 'sci.economic_barred AS customer_barred', + ]; + } + + $rows = $this->fetchRows( + "SELECT oa.id AS entity_id, oa.object_type, oa.object_id, oa.content" + . (!empty($temporalSelect) ? (', ' . implode(', ', $temporalSelect)) : '') + . ", o.customer_id AS customer_number, o.department_id, o.reference AS order_reference" + . (!empty($taskSelect) ? (', ' . implode(', ', $taskSelect)) : '') + . (!empty($customerSelect) ? (', ' . implode(', ', $customerSelect)) : '') + . " FROM object_attachments oa" + . " LEFT JOIN orders o ON oa.object_type = 'orders' AND o.id = oa.object_id AND o.deleted_at IS NULL" + . " LEFT JOIN department_selfserve_tasks dst ON oa.object_type = 'department_selfserve_tasks' AND dst.id = oa.object_id AND dst.deleted_at IS NULL" + . $taskDepartmentJoin + . $customerJoin + . " WHERE oa.deleted_at IS NULL" + ); + + $documents = []; + foreach ($rows as $row) { + $content = $row['content'] ?? null; + $contentText = is_string($content) ? $content : json_encode($content, JSON_UNESCAPED_UNICODE); + $attachmentName = ''; + $decodedContent = is_string($content) ? json_decode($content, true) : null; + if (is_array($decodedContent)) { + $attachmentName = trim((string)($decodedContent['other'] ?? '')); + } + + $title = trim($attachmentName); + if ($title === '') { + if (($row['object_type'] ?? '') === 'orders') { + $title = 'Order attachment #' . (string)($row['object_id'] ?? ''); + } elseif (($row['object_type'] ?? '') === 'department_selfserve_tasks') { + $title = 'Task attachment #' . (string)($row['object_id'] ?? ''); + } else { + $title = 'Attachment #' . (string)($row['entity_id'] ?? ''); + } + } + + $descriptionParts = []; + if (!empty($row['order_reference'])) { + $descriptionParts[] = 'Order ref ' . (string)$row['order_reference']; + } + if (!empty($row['customer_name'])) { + $descriptionParts[] = (string)$row['customer_name']; + } + if (!empty($row['task_task'])) { + $descriptionParts[] = (string)$row['task_task']; + } + if (!empty($row['task_department_name'])) { + $descriptionParts[] = (string)$row['task_department_name']; + } + if (!empty($row['task_description'])) { + $descriptionParts[] = (string)$row['task_description']; + } + + $documents[] = $this->makeDocument( + 'objects', + (string)($row['entity_id'] ?? ''), + $title, + implode(' / ', array_slice($descriptionParts, 0, 2)), + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['object_type'] ?? null, + $row['object_id'] ?? null, + $attachmentName, + $contentText, + $row['customer_number'] ?? null, + $row['customer_name'] ?? null, + $row['customer_email'] ?? null, + $row['customer_phone'] ?? null, + $row['customer_cvr'] ?? null, + $row['order_reference'] ?? null, + $row['task_task'] ?? null, + $row['task_department_name'] ?? null, + $row['task_description'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + $this->toIntOrNull($row['department_id'] ?? ($row['task_department'] ?? null)), + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'object_type' => $row['object_type'] ?? null, + 'object_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'linked_entity_type' => $row['object_type'] ?? null, + 'linked_entity_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'attachment_name' => $attachmentName !== '' ? $attachmentName : null, + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'customer_name' => $row['customer_name'] ?? null, + 'customer_email' => $row['customer_email'] ?? null, + 'customer_phone' => $row['customer_phone'] ?? null, + 'customer_cvr' => $row['customer_cvr'] ?? null, + 'customer_barred' => isset($row['customer_barred']) ? ((int)$row['customer_barred'] === 1) : null, + 'department_id' => $this->toIntOrNull($row['department_id'] ?? ($row['task_department'] ?? null)), + 'order_reference' => $row['order_reference'] ?? null, + 'task_title' => $row['task_task'] ?? null, + 'task_description' => $row['task_description'] ?? null, + 'task_department' => $this->toIntOrNull($row['task_department'] ?? null), + 'task_department_name' => $row['task_department_name'] ?? null, + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildGenericDocuments(string $entityType): array + { + $config = system_search_registry::genericEntityConfigs()[$entityType] ?? null; + if (!is_array($config)) { + return []; + } + + $table = trim((string)($config['table'] ?? '')); + if ($table === '' || !$this->tableExists($table)) { + return []; + } + + $columns = $this->getColumns($table); + if (empty($columns)) { + return []; + } + + $idField = (string)($config['id_field'] ?? (in_array('id', $columns, true) ? 'id' : $columns[0])); + if (!in_array($idField, $columns, true)) { + return []; + } + + $customerField = null; + if (isset($config['customer_field']) && is_string($config['customer_field']) && in_array($config['customer_field'], $columns, true)) { + $customerField = $config['customer_field']; + } + $customerFieldMode = isset($config['customer_field_mode']) && is_string($config['customer_field_mode']) + ? trim(mb_strtolower($config['customer_field_mode'])) + : 'default'; + if ($customerFieldMode === '') { + $customerFieldMode = 'default'; + } + + $departmentField = null; + if (isset($config['department_field']) && is_string($config['department_field']) && in_array($config['department_field'], $columns, true)) { + $departmentField = $config['department_field']; + } + + $excludedColumns = []; + if (isset($config['exclude_columns']) && is_array($config['exclude_columns'])) { + $excludedColumns = array_values(array_filter($config['exclude_columns'], static fn($value) => is_string($value) && $value !== '')); + } + + $searchable = []; + if (isset($config['search_fields']) && is_array($config['search_fields']) && !empty($config['search_fields'])) { + $configured = array_values(array_filter($config['search_fields'], static fn($value) => is_string($value) && $value !== '')); + $configured = array_values(array_intersect($configured, $columns)); + $searchable = $this->sanitizeGenericSearchFields($configured, $excludedColumns); + } + if (empty($searchable)) { + $searchable = $this->sanitizeGenericSearchFields($columns, $excludedColumns); + } + if (empty($searchable)) { + return []; + } + + $selectFields = array_values(array_unique(array_filter([ + $idField, + $customerField, + $departmentField, + ...$searchable, + ], static fn($value) => is_string($value) && $value !== ''))); + $selectFields = $this->appendTemporalColumns($table, $selectFields); + if (count($selectFields) > 32) { + $selectFields = array_slice($selectFields, 0, 32); + } + + $fixedConditions = []; + if (isset($config['fixed_conditions']) && is_array($config['fixed_conditions'])) { + foreach ($config['fixed_conditions'] as $column => $value) { + if (!is_string($column) || !in_array($column, $columns, true)) { + continue; + } + $fixedConditions[$column] = $value; + } + } + if (!array_key_exists('deleted_at', $fixedConditions) && in_array('deleted_at', $columns, true)) { + $fixedConditions['deleted_at'] = null; + } + + $whereClauses = []; + foreach ($fixedConditions as $column => $value) { + if ($value === null) { + $whereClauses[] = "`$column` IS NULL"; + } else { + $whereClauses[] = "`$column` = " . $this->sqlString((string)$value); + } + } + + $rows = $this->fetchRows( + "SELECT " . implode(', ', array_map(static fn($field) => "`$field`", $selectFields)) + . " FROM `$table`" + . (!empty($whereClauses) ? (' WHERE ' . implode(' AND ', $whereClauses)) : '') + ); + + $titleFields = []; + if (isset($config['title_fields']) && is_array($config['title_fields'])) { + $titleFields = array_values(array_filter($config['title_fields'], static fn($value) => is_string($value) && in_array($value, $selectFields, true))); + } + if (empty($titleFields)) { + $titleFields = array_values(array_intersect( + ['name', 'title', 'display_name', 'reference', 'reference_number', 'reg', 'reg_1', 'plate', 'module', 'customer_number', 'id'], + $selectFields + )); + } + + $descriptionFields = []; + if (isset($config['description_fields']) && is_array($config['description_fields'])) { + $descriptionFields = array_values(array_filter($config['description_fields'], static fn($value) => is_string($value) && in_array($value, $selectFields, true))); + } + if (empty($descriptionFields)) { + $descriptionFields = array_values(array_intersect( + ['description', 'note', 'notes', 'email', 'status', 'type', 'city', 'address', 'action', 'message', 'customer_id'], + $selectFields + )); + } + + $entityLabel = ucfirst(str_replace('_', ' ', $entityType)); + $documents = []; + foreach ($rows as $row) { + $entityId = isset($row[$idField]) ? (string)$row[$idField] : ''; + if ($entityId === '') { + continue; + } + + $title = ''; + foreach ($titleFields as $field) { + $value = trim((string)($row[$field] ?? '')); + if ($value === '') { + continue; + } + $title = $value; + break; + } + if ($title === '') { + $title = $entityLabel . ' #' . $entityId; + } + + $descriptionParts = []; + foreach ($descriptionFields as $field) { + $value = trim((string)($row[$field] ?? '')); + if ($value === '') { + continue; + } + $descriptionParts[] = $value; + if (count($descriptionParts) >= 2) { + break; + } + } + + $payload = array_intersect_key($row, array_flip($selectFields)); + $documents[] = $this->makeDocument( + $entityType, + $entityId, + $title, + implode(' / ', $descriptionParts), + $this->implodeSearchText(array_map(static fn($field) => $row[$field] ?? null, $searchable)), + $customerField !== null ? $this->resolveConfiguredCustomerNumber($row[$customerField] ?? null, $customerFieldMode) : null, + $departmentField !== null ? $this->toIntOrNull($row[$departmentField] ?? null) : null, + $payload, + $row + ); + } + + return $documents; + } + + /** + * @param array $candidateFields + * @param array $searchFields + * @param array $titleFields + * @param array $descriptionFields + * @return array> + */ + private function buildSimpleTableDocuments( + string $entityType, + string $table, + array $candidateFields, + array $searchFields, + array $titleFields, + array $descriptionFields + ): array { + if (!$this->tableExists($table)) { + return []; + } + + $fields = $this->appendTemporalColumns($table, $this->intersectExistingColumns($table, $candidateFields)); + if (empty($fields) || !in_array('id', $fields, true)) { + return []; + } + + $rows = $this->fetchRows( + "SELECT " . implode(', ', array_map(static fn($field) => "`$field`", $fields)) + . " FROM `$table`" + ); + + $documents = []; + foreach ($rows as $row) { + $entityId = isset($row['id']) ? (string)$row['id'] : ''; + if ($entityId === '') { + continue; + } + + $title = ''; + foreach ($titleFields as $field) { + $value = trim((string)($row[$field] ?? '')); + if ($value === '') { + continue; + } + $title = $value; + break; + } + if ($title === '') { + $title = ucfirst(rtrim(str_replace('_', ' ', $entityType), 's')) . ' #' . $entityId; + } + + $descriptionParts = []; + foreach ($descriptionFields as $field) { + $value = trim((string)($row[$field] ?? '')); + if ($value === '') { + continue; + } + $descriptionParts[] = $value; + } + + $documents[] = $this->makeDocument( + $entityType, + $entityId, + $title, + implode(' / ', array_slice($descriptionParts, 0, 2)), + $this->implodeSearchText(array_map(static fn($field) => $row[$field] ?? null, $searchFields)), + null, + null, + array_intersect_key($row, array_flip($fields)), + $row + ); + } + + return $documents; + } + + /** + * @param array> $documents + */ + private function replaceDocumentsForType(string $entityType, array $documents): void + { + global $db; + if (!is_object($db) || !method_exists($db, 'query')) { + return; + } + + $escapedType = $db->escape_string($entityType); + $db->query("DELETE FROM `" . self::TABLE . "` WHERE `entity_type` = '" . $escapedType . "'"); + + if (empty($documents)) { + return; + } + + foreach (array_chunk($documents, 100) as $chunk) { + $values = []; + foreach ($chunk as $document) { + $values[] = '(' + . $this->sqlString((string)$document['entity_type']) . ', ' + . $this->sqlString((string)$document['entity_id']) . ', ' + . $this->sqlNullableInt($document['customer_number'] ?? null) . ', ' + . $this->sqlNullableInt($document['department_id'] ?? null) . ', ' + . $this->sqlNullableString($document['title'] ?? null) . ', ' + . $this->sqlNullableString($document['description'] ?? null) . ', ' + . $this->sqlNullableString($document['search_text'] ?? null) . ', ' + . $this->sqlNullableString($document['payload_json'] ?? null) . ', ' + . $this->sqlNullableString($document['created_at'] ?? null) . ', ' + . $this->sqlNullableString($document['updated_at'] ?? null) + . ')'; + } + + $db->query( + "INSERT INTO `" . self::TABLE . "` " + . "(`entity_type`, `entity_id`, `customer_number`, `department_id`, `title`, `description`, `search_text`, `payload_json`, `created_at`, `updated_at`) VALUES " + . implode(', ', $values) + ); + } + } + + /** + * @param array $payload + * @param array $row + * @return array + */ + private function makeDocument( + string $entityType, + string $entityId, + string $title, + string $description, + string $searchText, + ?int $customerNumber, + ?int $departmentId, + array $payload, + array $row + ): array { + foreach (['created_at', 'updated_at'] as $column) { + if (array_key_exists($column, $row)) { + $payload[$column] = $row[$column]; + } + } + + return [ + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'customer_number' => $customerNumber, + 'department_id' => $departmentId, + 'title' => trim($title), + 'description' => trim($description), + 'search_text' => trim($searchText), + 'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE), + 'created_at' => isset($row['created_at']) ? (string)$row['created_at'] : null, + 'updated_at' => isset($row['updated_at']) ? (string)$row['updated_at'] : null, + ]; + } + + /** + * @param array $fields + * @return array + */ + private function appendTemporalColumns(string $table, array $fields): array + { + $columns = $this->getColumns($table); + foreach (['updated_at', 'created_at'] as $column) { + if (in_array($column, $columns, true) && !in_array($column, $fields, true)) { + $fields[] = $column; + } + } + return array_values(array_unique($fields)); + } + + /** + * @return array + */ + private function joinTemporalSelectFields(string $table, string $alias): array + { + $fields = []; + $columns = $this->getColumns($table); + $aliasPrefix = trim($alias) === '' ? '' : (trim($alias) . '.'); + foreach (['updated_at', 'created_at'] as $column) { + if (!in_array($column, $columns, true)) { + continue; + } + $fields[] = $aliasPrefix . $column . ' AS ' . $column; + } + return $fields; + } + + /** + * @param array $parts + */ + private function implodeSearchText(array $parts): string + { + return trim(implode(' ', array_values(array_filter(array_map( + static function ($value): ?string { + if ($value === null) { + return null; + } + $string = trim((string)$value); + return $string === '' ? null : $string; + }, + $parts + ))))); + } + + /** + * @param array $columns + * @param array $excludeColumns + * @return array + */ + private function sanitizeGenericSearchFields(array $columns, array $excludeColumns = []): array + { + $excluded = array_values(array_unique(array_map(static fn($value) => mb_strtolower((string)$value), $excludeColumns))); + $filtered = []; + foreach ($columns as $column) { + if (!is_string($column) || $column === '') { + continue; + } + $lower = mb_strtolower($column); + if (in_array($lower, $excluded, true)) { + continue; + } + if (in_array($lower, ['created_at', 'updated_at'], true)) { + continue; + } + if (preg_match('/(?:^|_)(password|token|secret|api_key|apikey|private|credential|passkey|session|hash|salt|client_secret|refresh_token|access_token)(?:_|$)/i', $lower)) { + continue; + } + if (in_array($lower, ['data', 'content', 'payload', 'config', 'permissions', 'washitems', 'client_secret'], true)) { + continue; + } + $filtered[] = $column; + } + return array_values(array_unique($filtered)); + } + + /** + * @return array> + */ + private function fetchRows(string $sql): array + { + global $db; + if (!is_object($db) || !method_exists($db, 'query')) { + return []; + } + + try { + $result = $db->query($sql); + if (!($result instanceof \mysqli_result)) { + return []; + } + return $db->fetch_all($result); + } catch (Throwable) { + return []; + } + } + + private function tableExists(string $table): bool + { + return !empty($this->getColumns($table)); + } + + /** + * @param array $candidateFields + * @return array + */ + private function intersectExistingColumns(string $table, array $candidateFields): array + { + $columns = $this->getColumns($table); + if (empty($columns)) { + return []; + } + return array_values(array_intersect($candidateFields, $columns)); + } + + /** + * @return array + */ + private function getColumns(string $table): array + { + if (isset($this->tableColumnsCache[$table])) { + return $this->tableColumnsCache[$table]; + } + + global $db; + try { + if (!is_object($db) || !method_exists($db, 'query')) { + $this->tableColumnsCache[$table] = []; + return []; + } + $result = $db->query("SHOW COLUMNS FROM `$table`"); + if (!($result instanceof \mysqli_result)) { + $this->tableColumnsCache[$table] = []; + return []; + } + $rows = $db->fetch_all($result); + $columns = array_values(array_map(static fn($row) => (string)$row['Field'], $rows)); + $this->tableColumnsCache[$table] = $columns; + return $columns; + } catch (Throwable) { + $this->tableColumnsCache[$table] = []; + return []; + } + } + + private function looksSecretVariable(string $variable): bool + { + $variable = mb_strtolower($variable); + return str_contains($variable, 'api_key') + || str_contains($variable, 'secret') + || str_contains($variable, 'password') + || str_contains($variable, 'token') + || str_contains($variable, 'private_key'); + } + + private function sqlString(string $value): string + { + global $db; + return "'" . $db->escape_string($value) . "'"; + } + + private function sqlNullableString(?string $value): string + { + if ($value === null || trim($value) === '') { + return 'NULL'; + } + return $this->sqlString($value); + } + + private function sqlNullableInt(mixed $value): string + { + $intValue = $this->toIntOrNull($value); + return $intValue === null ? 'NULL' : (string)$intValue; + } + + private function toIntOrNull(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + if (is_string($value) && preg_match('/^-?\d+$/', $value)) { + return (int)$value; + } + if (is_float($value)) { + return (int)$value; + } + return null; + } + + private function resolveConfiguredCustomerNumber(mixed $value, string $mode = 'default'): ?int + { + if ($mode !== 'digits_only') { + return $this->toIntOrNull($value); + } + + if (is_int($value)) { + return $value > 0 ? $value : null; + } + + if (!is_string($value)) { + return null; + } + + $trimmed = trim($value); + if ($trimmed === '' || !preg_match('/^\d+$/', $trimmed)) { + return null; + } + + $resolved = (int)$trimmed; + return $resolved > 0 ? $resolved : null; + } +} diff --git a/services/nginx/app/classes/system_search_economic_customer_index.php b/services/nginx/app/classes/system_search_economic_customer_index.php index 72afa4cc..db9a0667 100644 --- a/services/nginx/app/classes/system_search_economic_customer_index.php +++ b/services/nginx/app/classes/system_search_economic_customer_index.php @@ -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 $customerNumbers + * @return array> + */ + 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; diff --git a/services/nginx/app/classes/system_search_openai_intent_parser.php b/services/nginx/app/classes/system_search_openai_intent_parser.php index 81c280ef..cb5770c4 100644 --- a/services/nginx/app/classes/system_search_openai_intent_parser.php +++ b/services/nginx/app/classes/system_search_openai_intent_parser.php @@ -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; } diff --git a/services/nginx/app/classes/system_search_registry.php b/services/nginx/app/classes/system_search_registry.php new file mode 100644 index 00000000..74173fe0 --- /dev/null +++ b/services/nginx/app/classes/system_search_registry.php @@ -0,0 +1,277 @@ +> + */ + 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 + */ + 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 + */ + public static function indexedEntityTypes(): array + { + return array_values(array_diff(self::allEntityTypes(), ['permissions', 'subusers'])); + } + + /** + * @return array + */ + 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 $tables + * @return array + */ + 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> + */ + 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'], + ]; + } +} diff --git a/services/nginx/app/classes/system_search_service.php b/services/nginx/app/classes/system_search_service.php index a059f96b..9d69e717 100644 --- a/services/nginx/app/classes/system_search_service.php +++ b/services/nginx/app/classes/system_search_service.php @@ -33,12 +33,14 @@ class system_search_service 'module_config' => 90, ]; private array $tableColumnsCache = []; + private array $customerContextCache = []; public function __construct(?system_search_intent_parser_i $intentParser = null) { $this->intentParser = $intentParser ?? new system_search_openai_intent_parser(); try { system_search_economic_customer_index::ensureTable(); + system_search_document_index::ensureTable(); } catch (Throwable) { // Search should work even if index bootstrap is temporarily unavailable. } @@ -108,7 +110,8 @@ class system_search_service 'assoc' => $includeAssociations, 'dbg' => $debugIntent, 'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility), - 'v' => 7, + 'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)), + 'v' => 11, ], JSON_UNESCAPED_UNICODE)); $cached = system_search_cache::getQuery($queryCacheHash); @@ -316,20 +319,49 @@ class system_search_service array $forcedCustomerNumbers = [] ): array { $results = []; + $dirtyTables = system_search_cache::peekDirtyTables(); foreach ($activeTypes as $entityType) { $boost = (int)($entityBoost[$entityType] ?? 0); $ownOnly = in_array($entityType, $ownOnlyTypes, true); - $rows = $this->searchEntity( - $entityType, - $terms, - $boost, - $ownOnly, - $ownCustomerNumber, - $permissionsCatalogAll, - $permissionsCatalogOwn, - $moduleConfigVisibility, - $forcedCustomerNumbers - ); + if ($ownOnly && $ownCustomerNumber === null && empty($forcedCustomerNumbers)) { + continue; + } + if ($this->canUseIndexedSearch($entityType, $dirtyTables)) { + $rows = $this->searchIndexedEntity( + $entityType, + $terms, + $boost, + $ownOnly, + $ownCustomerNumber, + $moduleConfigVisibility, + $forcedCustomerNumbers + ); + if (empty($rows)) { + $rows = $this->searchEntity( + $entityType, + $terms, + $boost, + $ownOnly, + $ownCustomerNumber, + $permissionsCatalogAll, + $permissionsCatalogOwn, + $moduleConfigVisibility, + $forcedCustomerNumbers + ); + } + } else { + $rows = $this->searchEntity( + $entityType, + $terms, + $boost, + $ownOnly, + $ownCustomerNumber, + $permissionsCatalogAll, + $permissionsCatalogOwn, + $moduleConfigVisibility, + $forcedCustomerNumbers + ); + } $results = $this->mergeResults($results, $rows); } return $results; @@ -384,6 +416,218 @@ class system_search_service }; } + /** + * @param array $dirtyTables + */ + private function canUseIndexedSearch(string $entityType, array $dirtyTables): bool + { + if (!$this->tableExists(system_search_document_index::TABLE)) { + return false; + } + if (!in_array($entityType, system_search_registry::indexedEntityTypes(), true)) { + return false; + } + + $normalizedDirty = array_values(array_unique(array_filter(array_map( + static fn($table) => is_string($table) ? trim($table, " `\t\n\r\0\x0B") : '', + $dirtyTables + )))); + if (empty($normalizedDirty)) { + return true; + } + + return empty(array_intersect($normalizedDirty, system_search_registry::sourceTablesForEntityType($entityType))); + } + + /** + * @param array $terms + * @param array $moduleConfigVisibility + * @param array $forcedCustomerNumbers + * @return array> + */ + private function searchIndexedEntity( + string $entityType, + array $terms, + int $entityBoost, + bool $ownOnly, + ?int $ownCustomerNumber, + array $moduleConfigVisibility, + array $forcedCustomerNumbers + ): array { + global $db; + + if (!$this->tableExists(system_search_document_index::TABLE) || empty($terms)) { + return []; + } + + $customerNumbers = !empty($forcedCustomerNumbers) + ? $forcedCustomerNumbers + : (($ownOnly && $ownCustomerNumber !== null) ? [$ownCustomerNumber] : []); + if ($ownOnly && empty($customerNumbers)) { + return []; + } + + $wheres = [ + "`entity_type` = '" . $db->escape_string($entityType) . "'", + ]; + if (!empty($customerNumbers)) { + $wheres[] = "`customer_number` IN (" . implode(',', array_map('intval', $customerNumbers)) . ")"; + } + + $booleanQuery = $this->buildBooleanFullTextQuery($terms); + $rows = []; + if ($booleanQuery !== null) { + $escapedBoolean = $db->escape_string($booleanQuery); + $rows = $this->runSelectRows( + "SELECT entity_id, customer_number, department_id, title, description, search_text, payload_json, created_at, updated_at, " + . "MATCH(title, description, search_text) AGAINST ('" . $escapedBoolean . "' IN BOOLEAN MODE) AS indexed_score " + . "FROM `" . system_search_document_index::TABLE . "` " + . "WHERE " . implode(' AND ', $wheres) + . " AND MATCH(title, description, search_text) AGAINST ('" . $escapedBoolean . "' IN BOOLEAN MODE)" + . " ORDER BY indexed_score DESC LIMIT " . $this->defaultEntityFetchLimit + ); + } + + if (empty($rows)) { + $termClauses = []; + foreach ($terms as $term) { + $escaped = $db->escape_string($term); + foreach (['title', 'description', 'search_text'] as $field) { + $termClauses[] = "`$field` LIKE '%$escaped%'"; + } + } + if (empty($termClauses)) { + return []; + } + $rows = $this->runSelectRows( + "SELECT entity_id, customer_number, department_id, title, description, search_text, payload_json, created_at, updated_at, 0 AS indexed_score " + . "FROM `" . system_search_document_index::TABLE . "` " + . "WHERE " . implode(' AND ', $wheres) + . " AND (" . implode(' OR ', $termClauses) . ")" + . " LIMIT " . $this->defaultEntityFetchLimit + ); + } + + $this->primeCustomerContexts(array_values(array_unique(array_filter( + array_map(fn(array $row): ?int => $this->toIntOrNull($row['customer_number'] ?? null), $rows), + static fn(?int $value): bool => $value !== null && $value > 0 + )))); + + $invoiceTitleContexts = $entityType === 'invoices' + ? $this->loadInvoiceTitleContexts(array_map(static fn(array $row): mixed => $row['entity_id'] ?? null, $rows)) + : []; + + $results = []; + foreach ($rows as $row) { + $payload = []; + $payloadJson = $row['payload_json'] ?? null; + if (is_string($payloadJson) && $payloadJson !== '') { + $decoded = json_decode($payloadJson, true); + if (is_array($decoded)) { + $payload = $decoded; + } + } + + $title = (string)($row['title'] ?? ''); + if ($entityType === 'invoices') { + $invoiceContext = $invoiceTitleContexts[(string)($row['entity_id'] ?? '')] ?? []; + foreach (['name', 'created_at', 'closed_at'] as $field) { + if (array_key_exists($field, $invoiceContext)) { + $payload[$field] = $invoiceContext[$field]; + } + } + + $storedTitle = trim($title); + $storedName = null; + if ($storedTitle !== '' && !str_starts_with($storedTitle, 'Invoice collection #')) { + $storedName = $storedTitle; + } + + $title = $this->invoiceResultTitle( + $payload['name'] ?? $storedName, + $payload['created_at'] ?? ($row['created_at'] ?? null), + $payload['closed_at'] ?? null, + $row['entity_id'] ?? null + ); + } + + if ($entityType === 'module_config') { + $module = (string)($payload['module'] ?? ''); + if ($module !== '' && isset($moduleConfigVisibility[$module]) && !$moduleConfigVisibility[$module]) { + continue; + } + $variable = (string)($payload['variable'] ?? ''); + if ($variable !== '' && $this->looksSecretVariable($variable)) { + continue; + } + } + + $indexedBoost = (int)round(max(0.0, (float)($row['indexed_score'] ?? 0.0)) * 40); + $score = $this->scoreRow([ + 'title' => $title, + 'description' => $row['description'] ?? '', + 'search_text' => $row['search_text'] ?? '', + ], ['title' => 4, 'description' => 2, 'search_text' => 1], $terms) + $indexedBoost + $entityBoost; + if ($score <= 0) { + continue; + } + + $results[] = $this->decorateSearchResultWithCustomerContext([ + 'entity_type' => $entityType, + 'entity_id' => (string)($row['entity_id'] ?? ''), + 'title' => $title, + 'description' => (string)($row['description'] ?? ''), + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'department_id' => $this->toIntOrNull($row['department_id'] ?? null), + 'score' => $score, + 'payload' => $payload, + ]); + } + + return $results; + } + + /** + * @param array $entityIds + * @return array + */ + private function loadInvoiceTitleContexts(array $entityIds): array + { + if (!$this->tableExists('collected_order_invoices')) { + return []; + } + + $invoiceIds = array_values(array_unique(array_filter(array_map( + fn(mixed $entityId): ?int => $this->toIntOrNull($entityId), + $entityIds + ), static fn(?int $invoiceId): bool => $invoiceId !== null && $invoiceId > 0))); + if (empty($invoiceIds)) { + return []; + } + + $rows = $this->runSelectRows( + "SELECT id, name, created_at, closed_at" + . " FROM `collected_order_invoices`" + . " WHERE `deleted_at` IS NULL" + . " AND `id` IN (" . implode(',', $invoiceIds) . ")" + ); + + $contexts = []; + foreach ($rows as $row) { + if (!isset($row['id'])) { + continue; + } + + $contexts[(string)$row['id']] = [ + 'name' => array_key_exists('name', $row) ? $row['name'] : null, + 'created_at' => array_key_exists('created_at', $row) ? $row['created_at'] : null, + 'closed_at' => array_key_exists('closed_at', $row) ? $row['closed_at'] : null, + ]; + } + + return $contexts; + } + private function searchCustomers(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array { $customerNumbers = !empty($forcedCustomerNumbers) @@ -531,6 +775,7 @@ class system_search_service $terms, $customerNumbers, 'customer_id', + 'default', ['deleted_at' => null] ); @@ -603,11 +848,12 @@ class system_search_service $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : ($ownOnly && $ownCustomerNumber ? [$ownCustomerNumber] : []); $rows = $this->searchTable( 'collected_order_invoices', - ['id', 'customer_number', 'name', 'notes', 'external_id', 'booked_invoice_id', 'po_number', 'deleted_at'], + ['id', 'customer_number', 'name', 'notes', 'external_id', 'booked_invoice_id', 'po_number', 'closed_at', 'deleted_at'], ['id', 'customer_number', 'name', 'notes', 'external_id', 'booked_invoice_id', 'po_number'], $terms, $customerNumbers, 'customer_number', + 'default', ['deleted_at' => null] ); @@ -615,7 +861,12 @@ class system_search_service return [ 'entity_type' => 'invoices', 'entity_id' => (string)$row['id'], - 'title' => (string)($row['name'] ?: ('Invoice collection #' . $row['id'])), + 'title' => $this->invoiceResultTitle( + $row['name'] ?? null, + $row['created_at'] ?? null, + $row['closed_at'] ?? null, + $row['id'] ?? null + ), '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, @@ -624,11 +875,64 @@ class system_search_service 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'name' => $row['name'] ?? null, 'external_id' => $row['external_id'] ?? null, + 'closed_at' => $row['closed_at'] ?? null, ], $row), ]; }, $rows); } + private function invoiceResultTitle(mixed $name, mixed $fromDate, mixed $toDate, mixed $invoiceId): string + { + $resolvedName = trim((string)($name ?? '')); + if ($resolvedName !== '') { + return $resolvedName; + } + + $dateRange = $this->invoiceDateRangeLabel($fromDate, $toDate); + if ($dateRange !== '') { + return $dateRange; + } + + return 'Invoice collection #' . (string)$invoiceId; + } + + private function invoiceDateRangeLabel(mixed $fromDate, mixed $toDate): string + { + $fromLabel = $this->invoiceDateLabel($fromDate); + $toLabel = $this->invoiceDateLabel($toDate); + + if ($fromLabel !== '' && $toLabel !== '' && $fromLabel !== $toLabel) { + return $fromLabel . ' - ' . $toLabel; + } + if ($fromLabel !== '') { + return $fromLabel; + } + if ($toLabel !== '') { + return $toLabel; + } + + return ''; + } + + private function invoiceDateLabel(mixed $value): string + { + if ($value === null) { + return ''; + } + + $raw = trim((string)$value); + if ($raw === '' || $raw === '0000-00-00' || $raw === '0000-00-00 00:00:00') { + return ''; + } + + $timestamp = strtotime($raw); + if ($timestamp === false) { + return ''; + } + + return date('Y-m-d', $timestamp); + } + private function searchVehicles(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array { $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : ($ownOnly && $ownCustomerNumber ? [$ownCustomerNumber] : []); @@ -639,6 +943,7 @@ class system_search_service $terms, $customerNumbers, 'customer_id', + 'default', ['deleted_at' => null] ); @@ -1012,33 +1317,109 @@ class system_search_service private function searchObjects(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array { - if ($ownOnly && $ownCustomerNumber !== null) { + global $db; + + if ($ownOnly && $ownCustomerNumber === null) { return []; } - $rows = $this->searchTable( - 'object_attachments', - ['id', 'object_type', 'object_id', 'content', 'deleted_at'], - ['id', 'object_type', 'object_id', 'content'], - $terms, - [], - null, - ['deleted_at' => null] + + $taskColumns = $this->getColumns('department_selfserve_tasks'); + $taskSelectFields = []; + $taskSearchFields = []; + if (in_array('task', $taskColumns, true)) { + $taskSelectFields[] = 'dst.task AS task_title'; + $taskSearchFields[] = 'dst.task'; + } + if (in_array('description', $taskColumns, true)) { + $taskSelectFields[] = 'dst.description AS task_description'; + $taskSearchFields[] = 'dst.description'; + } + if (in_array('department', $taskColumns, true)) { + $taskSelectFields[] = 'dst.department AS task_department'; + } + + $taskDepartmentJoin = ''; + if ($this->tableExists('departments') && in_array('department', $taskColumns, true) && in_array('name', $this->getColumns('departments'), true)) { + $taskDepartmentJoin = ' LEFT JOIN departments d ON d.id = dst.department'; + $taskSelectFields[] = 'd.name AS task_department_name'; + $taskSearchFields[] = 'd.name'; + } + + $customerJoin = ''; + $customerSelectFields = []; + $customerSearchFields = ['o.customer_id', 'o.reference']; + if ($this->tableExists(system_search_economic_customer_index::TABLE)) { + $customerJoin = " LEFT JOIN `" . system_search_economic_customer_index::TABLE . "` sci ON sci.customer_number = o.customer_id"; + $customerSelectFields = [ + "COALESCE(sci.economic_name, sci.local_display_name) AS customer_name", + "COALESCE(sci.economic_email, sci.local_email) AS customer_email", + "COALESCE(sci.economic_mobile_phone, sci.local_phone) AS customer_phone", + "sci.economic_cvr AS customer_cvr", + "sci.economic_barred AS customer_barred", + ]; + $customerSearchFields = [ + ...$customerSearchFields, + 'sci.economic_name', + 'sci.local_display_name', + 'sci.economic_email', + 'sci.local_email', + 'sci.economic_mobile_phone', + 'sci.local_phone', + 'sci.economic_cvr', + ]; + } + + $termClauses = []; + foreach ($terms as $term) { + $escaped = $db->escape_string($term); + foreach (array_values(array_unique([ + 'oa.id', + 'oa.object_type', + 'oa.object_id', + 'oa.content', + ...$customerSearchFields, + ...$taskSearchFields, + ])) as $field) { + $termClauses[] = $field . " LIKE '%$escaped%'"; + } + } + if (empty($termClauses)) { + return []; + } + + $scopeClauses = []; + if ($ownOnly) { + $scopeClauses[] = "(oa.object_type = 'orders' AND o.customer_id = " . (int)$ownCustomerNumber . ")"; + } else { + $scopeClauses[] = "(oa.object_type = 'orders' AND o.id IS NOT NULL)"; + $scopeClauses[] = "(oa.object_type = 'department_selfserve_tasks' AND dst.id IS NOT NULL)"; + } + + $rows = $this->runSelectRows( + "SELECT oa.id, oa.object_type, oa.object_id, oa.content, oa.created_at, oa.updated_at," + . " o.customer_id AS customer_number, o.department_id, o.reference AS order_reference," + . (!empty($taskSelectFields) ? (' ' . ', ' . implode(', ', array_values(array_unique($taskSelectFields)))) : '') + . (!empty($customerSelectFields) ? (' ' . ', ' . implode(', ', $customerSelectFields)) : '') + . " FROM object_attachments oa" + . " LEFT JOIN orders o ON oa.object_type = 'orders' AND o.id = oa.object_id AND o.deleted_at IS NULL" + . " LEFT JOIN department_selfserve_tasks dst ON oa.object_type = 'department_selfserve_tasks' AND dst.id = oa.object_id AND dst.deleted_at IS NULL" + . $taskDepartmentJoin + . $customerJoin + . " WHERE oa.deleted_at IS NULL" + . " AND (" . implode(' OR ', $scopeClauses) . ")" + . " AND (" . implode(' OR ', $termClauses) . ")" + . " LIMIT " . $this->defaultEntityFetchLimit ); - return array_map(function (array $row) use ($terms, $entityBoost) { - return [ - 'entity_type' => 'objects', - 'entity_id' => (string)$row['id'], - 'title' => (string)($row['object_type'] ?? 'object_attachment') . '#' . (string)$row['object_id'], - 'description' => (string)$row['content'], - 'score' => $this->scoreRow($row, ['id', 'object_type', 'object_id', 'content'], $terms) + $entityBoost, - 'payload' => $this->augmentPayloadWithTemporal([ - 'id' => (int)$row['id'], - 'object_type' => $row['object_type'] ?? null, - 'object_id' => $row['object_id'] ?? null, - ], $row), - ]; - }, $rows); + $this->primeCustomerContexts(array_values(array_unique(array_filter( + array_map(fn(array $row): ?int => $this->toIntOrNull($row['customer_number'] ?? null), $rows), + static fn(?int $value): bool => $value !== null && $value > 0 + )))); + + return array_map( + fn(array $row): array => $this->buildObjectSearchResult($row, $terms, $entityBoost), + $rows + ); } private function isGenericEntityType(string $entityType): bool @@ -1102,6 +1483,12 @@ class system_search_service if (isset($config['customer_field']) && is_string($config['customer_field']) && in_array($config['customer_field'], $columns, true)) { $customerField = $config['customer_field']; } + $customerFieldMode = isset($config['customer_field_mode']) && is_string($config['customer_field_mode']) + ? trim(mb_strtolower($config['customer_field_mode'])) + : 'default'; + if ($customerFieldMode === '') { + $customerFieldMode = 'default'; + } if ($ownOnly) { if ($customerField === null) { @@ -1170,9 +1557,20 @@ class system_search_service $terms, $customerNumbers, $customerField, + $customerFieldMode, $fixedConditions ); + $this->primeCustomerContexts(array_values(array_unique(array_filter( + array_map( + fn(array $row): ?int => ($customerField !== null && array_key_exists($customerField, $row)) + ? $this->resolveConfiguredCustomerNumber($row[$customerField], $customerFieldMode) + : null, + $rows + ), + static fn(?int $value): bool => $value !== null && $value > 0 + )))); + $titleFields = []; if (isset($config['title_fields']) && is_array($config['title_fields'])) { $titleFields = array_values(array_filter($config['title_fields'], static fn($v) => is_string($v) && in_array($v, $selectFields, true))); @@ -1225,19 +1623,21 @@ class system_search_service } $description = implode(' / ', $descriptionParts); - $results[] = [ + $results[] = $this->decorateSearchResultWithCustomerContext([ 'entity_type' => $entityType, 'entity_id' => $entityId, 'title' => $title, 'description' => $description, - 'customer_number' => ($customerField !== null && isset($row[$customerField])) ? $this->toIntOrNull($row[$customerField]) : null, + 'customer_number' => ($customerField !== null && array_key_exists($customerField, $row)) + ? $this->resolveConfiguredCustomerNumber($row[$customerField], $customerFieldMode) + : null, 'department_id' => ($departmentField !== null && isset($row[$departmentField])) ? $this->toIntOrNull($row[$departmentField]) : null, 'score' => $this->scoreRow($row, $searchable, $terms) + $entityBoost, 'payload' => $this->augmentPayloadWithTemporal( array_intersect_key($row, array_flip([...$selectFields, 'updated_at', 'created_at'])), $row ), - ]; + ]); } return $results; @@ -1279,58 +1679,7 @@ class system_search_service */ private function genericEntityConfigs(): array { - return [ - 'bookings' => ['table' => 'bookings', 'customer_field' => 'customer_number', 'department_field' => 'department'], - 'bookings_new' => ['table' => 'bookings_new', 'customer_field' => 'customer_number', 'department_field' => 'department'], - 'branding' => ['table' => 'branding'], - 'categories' => ['table' => 'categories'], - 'currency_conversion_rates' => ['table' => 'currency_conversion_rates'], - 'customer_codes' => ['table' => 'customer_codes'], - 'customer_default_department' => ['table' => 'customer_default_department', 'customer_field' => 'customer_number', 'department_field' => 'department'], - 'customer_notes' => ['table' => 'customer_notes', 'customer_field' => 'customer_id'], - 'customer_vehicles_addons' => ['table' => 'customer_vehicles_addons'], - 'department_categories' => ['table' => 'department_categories', 'department_field' => 'department'], - 'department_daily_reports' => ['table' => 'department_daily_reports', 'department_field' => 'department_id'], - 'department_gates' => ['table' => 'department_gates', 'department_field' => 'department'], - 'department_goals' => ['table' => 'goals'], - 'department_lanes' => ['table' => 'department_lanes', 'department_field' => 'department'], - 'department_notification_sms' => ['table' => 'department_notification_sms', 'department_field' => 'department_id'], - 'department_relays' => ['table' => 'department_relays', 'department_field' => 'department'], - 'department_selfserve_condition_rules' => ['table' => 'department_selfserve_condition_rules'], - 'department_selfserve_conditions' => ['table' => 'department_selfserve_conditions', 'department_field' => 'department'], - 'department_selfserve_questions' => ['table' => 'department_selfserve_questions', 'department_field' => 'department'], - 'department_selfserve_tasks' => ['table' => 'department_selfserve_tasks', 'department_field' => 'department'], - 'department_selfserve_vehicle_conditions' => ['table' => 'department_selfserve_vehicle_conditions', 'customer_field' => 'customer_id', 'department_field' => 'department'], - 'department_time_bookings_entries' => ['table' => 'department_time_bookings_entries', 'department_field' => 'department'], - 'department_time_bookings_opening_hours' => ['table' => 'department_time_bookings_opening_hours', 'department_field' => 'department'], - 'department_time_bookings_types' => ['table' => 'department_time_bookings_types', 'department_field' => 'department'], - 'department_variables' => ['table' => 'department_variables'], - 'fxratesapi_conversion_rates' => ['table' => 'fxratesapi_conversion_rates'], - 'module_action_logs' => ['table' => 'module_usage_logs'], - 'motorapi_lookups' => ['table' => 'motorapi_lookups'], - 'notifications' => ['table' => 'notifications'], - 'order_bookings' => ['table' => 'order_bookings', 'customer_field' => 'customer_number', 'department_field' => 'department'], - 'plate_scanners' => ['table' => 'plate_scanners', 'department_field' => 'department_id'], - 'plate_scans' => ['table' => 'plate_scans'], - 'product_options' => ['table' => 'products_options'], - 'products' => ['table' => 'products'], - 'users' => [ - 'table' => 'users', - 'customer_field' => 'customer_number', - 'title_fields' => ['display_name', 'email', 'customer_number', 'id'], - 'description_fields' => ['email', 'phone', 'role'], - ], - 'stripe_module_customers' => ['table' => 'stripe_module_customers', 'customer_field' => 'customer_id'], - 'stripe_module_orders' => ['table' => 'stripe_module_orders', 'customer_field' => 'customer_id', 'exclude_columns' => ['url']], - 'stripe_payment_intents' => ['table' => 'stripe_payment_intents', 'exclude_columns' => ['client_secret', 'data']], - 'subuser_grants' => ['table' => 'subuser_grants', 'customer_field' => 'billing_customer_number'], - 'xlvask_customers' => ['table' => 'xlvask_customers'], - 'xlvask_potential_order_matches' => ['table' => 'xlvask_potential_order_matches', 'customer_field' => 'customer_number', 'department_field' => 'department'], - 'xlvask_usage_log_wash_items' => ['table' => 'xlvask_usage_log_wash_items'], - 'xlvask_usage_logs' => ['table' => 'xlvask_usage_logs'], - 'xlvask_vehicle_types' => ['table' => 'xlvask_vehicle_types'], - 'xlvask_vehicles' => ['table' => 'xlvask_vehicles'], - ]; + return system_search_registry::genericEntityConfigs(); } private function toIntOrNull(mixed $value): ?int @@ -1347,6 +1696,51 @@ class system_search_service return null; } + private function resolveConfiguredCustomerNumber(mixed $value, string $mode = 'default'): ?int + { + if ($mode !== 'digits_only') { + return $this->toIntOrNull($value); + } + + if (is_int($value)) { + return $value > 0 ? $value : null; + } + + if (!is_string($value)) { + return null; + } + + $trimmed = trim($value); + if ($trimmed === '' || !preg_match('/^\d+$/', $trimmed)) { + return null; + } + + $resolved = (int)$trimmed; + return $resolved > 0 ? $resolved : null; + } + + /** + * @param array $customerNumbers + */ + private function customerFieldFilterClause(string $customerField, array $customerNumbers, string $customerFieldMode = 'default'): string + { + $normalizedNumbers = array_values(array_unique(array_filter( + array_map('intval', $customerNumbers), + static fn(int $value): bool => $value > 0 + ))); + if (empty($normalizedNumbers)) { + return ''; + } + + if ($customerFieldMode === 'digits_only') { + return "TRIM(`$customerField`) REGEXP '^[0-9]+$' AND CAST(TRIM(`$customerField`) AS UNSIGNED) IN (" + . implode(',', $normalizedNumbers) + . ")"; + } + + return "`$customerField` IN (" . implode(',', $normalizedNumbers) . ")"; + } + /** * Generic table search helper. * @@ -1355,6 +1749,7 @@ class system_search_service * @param array $terms * @param array $customerNumbers * @param string|null $customerField + * @param string $customerFieldMode * @param array $fixedConditions * @return array> */ @@ -1365,6 +1760,7 @@ class system_search_service array $terms, array $customerNumbers = [], ?string $customerField = null, + string $customerFieldMode = 'default', array $fixedConditions = [] ): array { global $db; @@ -1395,7 +1791,10 @@ class system_search_service } if (!empty($customerNumbers) && $customerField !== null && in_array($customerField, $fields, true)) { - $wheres[] = "`$customerField` IN (" . implode(',', array_map('intval', $customerNumbers)) . ")"; + $customerFilterClause = $this->customerFieldFilterClause($customerField, $customerNumbers, $customerFieldMode); + if ($customerFilterClause !== '') { + $wheres[] = $customerFilterClause; + } } $termClauses = []; @@ -1417,11 +1816,7 @@ class system_search_service . " FROM `$table`" . " WHERE " . implode(' AND ', $wheres) . " LIMIT " . $this->defaultEntityFetchLimit; - $result = $db->query($sql); - if (!($result instanceof \mysqli_result)) { - return []; - } - return $db->fetch_all($result); + return $this->runSelectRows($sql); } /** @@ -1462,19 +1857,49 @@ class system_search_service . " WHERE " . $baseWhere . " AND (" . implode(' OR ', $termClauses) . ")" . " LIMIT " . $this->defaultEntityFetchLimit; - $result = $db->query($sql); - if (!($result instanceof \mysqli_result)) { + return $this->runSelectRows($sql); + } + + /** + * @return array> + */ + protected function runSelectRows(string $sql): array + { + global $db; + try { + $result = $db->query($sql); + if (!($result instanceof \mysqli_result)) { + return []; + } + return $db->fetch_all($result); + } catch (Throwable) { return []; } - return $db->fetch_all($result); } private function scoreRow(array $row, array $fields, array $terms): int { + $fieldWeights = []; + foreach ($fields as $key => $value) { + if (is_string($key)) { + $fieldWeights[$key] = max(1, (int)$value); + continue; + } + if (is_string($value)) { + $fieldWeights[$value] = 1; + } + } + if (empty($fieldWeights) || empty($terms)) { + return 0; + } + + $contentTerms = $this->contentTerms($terms); + $matchedTerms = []; $score = 0; foreach ($terms as $term) { $termLower = mb_strtolower($term); - foreach ($fields as $field) { + $bestScore = 0; + foreach ($fieldWeights as $field => $weight) { if (!array_key_exists($field, $row) || $row[$field] === null) { continue; } @@ -1483,29 +1908,90 @@ class system_search_service continue; } $valueLower = mb_strtolower($value); + $baseScore = 0; if ($valueLower === $termLower) { - $score += 100; - continue; - } - if (str_starts_with($valueLower, $termLower)) { - $score += 60; - continue; - } - if (str_contains($valueLower, $termLower)) { - $score += 30; - continue; - } - if (strlen($termLower) >= 4 && strlen($valueLower) <= 64) { + $baseScore = 100; + } elseif (str_starts_with($valueLower, $termLower)) { + $baseScore = 60; + } elseif (str_contains($valueLower, $termLower)) { + $baseScore = 30; + } elseif (strlen($termLower) >= 4 && strlen($valueLower) <= 64) { $distance = levenshtein($termLower, $valueLower); if ($distance <= 2) { - $score += 20 - ($distance * 5); + $baseScore = 20 - ($distance * 5); } } + if ($baseScore <= 0) { + continue; + } + $bestScore = max($bestScore, $baseScore * $weight); + } + + if ($bestScore > 0) { + $score += $bestScore; + if (in_array($termLower, $contentTerms, true)) { + $matchedTerms[$termLower] = true; + } } } + + if (count($contentTerms) > 1) { + $requiredMatches = $this->minimumTermMatches($contentTerms); + if (count($matchedTerms) < $requiredMatches) { + return 0; + } + } + return $score; } + /** + * @param array $terms + * @return array + */ + private function contentTerms(array $terms): array + { + $stopwords = [ + 'a', 'an', 'and', 'at', 'between', 'find', 'for', 'fra', 'from', 'har', 'have', + 'hvilke', 'hvor', 'i', 'med', 'need', 'of', 'og', 'or', 'search', 'show', 'som', + 'the', 'til', 'uden', 'want', 'where', 'which', 'with', 'without', + 'booking', 'bookings', 'customer', 'customers', 'discount', 'discounts', 'faktura', + 'invoice', 'invoices', 'kunde', 'kunder', 'order', 'orders', 'rabat', 'user', 'users', + 'vehicle', 'vehicles', + ]; + + $filtered = []; + foreach ($terms as $term) { + $normalized = trim(mb_strtolower((string)$term)); + if ($normalized === '' || in_array($normalized, $stopwords, true)) { + continue; + } + $filtered[] = $normalized; + } + + $filtered = array_values(array_unique($filtered)); + if (!empty($filtered)) { + return $filtered; + } + + return array_values(array_unique(array_map(static fn($term) => mb_strtolower((string)$term), $terms))); + } + + /** + * @param array $terms + */ + private function minimumTermMatches(array $terms): int + { + $count = count($terms); + if ($count <= 1) { + return 1; + } + if ($count === 2) { + return 2; + } + return min(3, max(2, (int)ceil($count / 2))); + } + private function tableExists(string $table): bool { return !empty($this->getColumns($table)); @@ -1622,6 +2108,354 @@ class system_search_service return $payload; } + /** + * @param array $row + * @param array $terms + * @return array + */ + private function buildObjectSearchResult(array $row, array $terms, int $entityBoost): array + { + $context = $this->resolveObjectSearchContext($row); + $result = [ + 'entity_type' => 'objects', + 'entity_id' => (string)($row['id'] ?? ''), + 'title' => (string)$context['title'], + 'description' => (string)$context['description'], + 'customer_number' => $context['customer_number'], + 'department_id' => $context['department_id'], + 'score' => $this->scoreRow($row, [ + 'id' => 3, + 'object_type' => 2, + 'object_id' => 2, + 'content' => 1, + 'customer_number' => 3, + 'customer_name' => 4, + 'customer_email' => 2, + 'customer_cvr' => 2, + 'order_reference' => 3, + 'task_title' => 3, + 'task_description' => 2, + 'task_department_name' => 2, + ], $terms) + $entityBoost, + 'payload' => $this->augmentPayloadWithTemporal((array)$context['payload'], $row), + ]; + + return $this->decorateSearchResultWithCustomerContext($result); + } + + /** + * @param array $row + * @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array} + */ + private function resolveObjectSearchContext(array $row): array + { + return match (trim(mb_strtolower((string)($row['object_type'] ?? '')))) { + 'orders' => $this->resolveOrderObjectSearchContext($row), + 'department_selfserve_tasks' => $this->resolveTaskObjectSearchContext($row), + default => $this->resolveGenericObjectSearchContext($row), + }; + } + + /** + * @param array $row + * @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array} + */ + private function resolveOrderObjectSearchContext(array $row): array + { + $attachmentName = $this->attachmentNameFromContent($row['content'] ?? null); + $title = $attachmentName !== '' ? $attachmentName : ('Order attachment #' . (string)($row['object_id'] ?? '')); + $customerNumber = $this->toIntOrNull($row['customer_number'] ?? null); + $departmentId = $this->toIntOrNull($row['department_id'] ?? null); + $customerName = trim((string)($row['customer_name'] ?? '')); + + $descriptionParts = []; + $orderReference = trim((string)($row['order_reference'] ?? '')); + if ($orderReference !== '') { + $descriptionParts[] = $orderReference; + } + if ($customerName !== '') { + $descriptionParts[] = $customerName; + } + + return [ + 'title' => $title, + 'description' => implode(' / ', array_slice($descriptionParts, 0, 2)), + 'customer_number' => $customerNumber, + 'department_id' => $departmentId, + 'payload' => [ + 'id' => $this->toIntOrNull($row['id'] ?? null), + 'object_type' => $row['object_type'] ?? null, + 'object_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'linked_entity_type' => 'orders', + 'linked_entity_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'attachment_name' => $attachmentName !== '' ? $attachmentName : null, + 'customer_number' => $customerNumber, + 'department_id' => $departmentId, + 'order_reference' => $row['order_reference'] ?? null, + ], + ]; + } + + /** + * @param array $row + * @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array} + */ + private function resolveTaskObjectSearchContext(array $row): array + { + $attachmentName = $this->attachmentNameFromContent($row['content'] ?? null); + $title = $attachmentName !== '' ? $attachmentName : ('Task attachment #' . (string)($row['object_id'] ?? '')); + $departmentId = $this->toIntOrNull($row['task_department'] ?? null); + + $descriptionParts = []; + foreach (['task_title', 'task_department_name', 'task_description'] as $field) { + $value = trim((string)($row[$field] ?? '')); + if ($value === '') { + continue; + } + $descriptionParts[] = $value; + if (count($descriptionParts) >= 2) { + break; + } + } + + return [ + 'title' => $title, + 'description' => implode(' / ', $descriptionParts), + 'customer_number' => null, + 'department_id' => $departmentId, + 'payload' => [ + 'id' => $this->toIntOrNull($row['id'] ?? null), + 'object_type' => $row['object_type'] ?? null, + 'object_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'linked_entity_type' => 'department_selfserve_tasks', + 'linked_entity_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'attachment_name' => $attachmentName !== '' ? $attachmentName : null, + 'department_id' => $departmentId, + 'task_title' => $row['task_title'] ?? null, + 'task_description' => $row['task_description'] ?? null, + 'task_department' => $departmentId, + 'task_department_name' => $row['task_department_name'] ?? null, + ], + ]; + } + + /** + * @param array $row + * @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array} + */ + private function resolveGenericObjectSearchContext(array $row): array + { + $attachmentName = $this->attachmentNameFromContent($row['content'] ?? null); + $title = $attachmentName !== '' + ? $attachmentName + : ((string)($row['object_type'] ?? 'object_attachment') . '#' . (string)($row['object_id'] ?? '')); + + return [ + 'title' => $title, + 'description' => '', + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'department_id' => $this->toIntOrNull($row['department_id'] ?? ($row['task_department'] ?? null)), + 'payload' => [ + 'id' => $this->toIntOrNull($row['id'] ?? null), + 'object_type' => $row['object_type'] ?? null, + 'object_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'linked_entity_type' => $row['object_type'] ?? null, + 'linked_entity_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'attachment_name' => $attachmentName !== '' ? $attachmentName : null, + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'department_id' => $this->toIntOrNull($row['department_id'] ?? ($row['task_department'] ?? null)), + ], + ]; + } + + private function attachmentNameFromContent(mixed $content): string + { + $decodedContent = is_string($content) ? json_decode($content, true) : null; + if (!is_array($decodedContent)) { + return ''; + } + return trim((string)($decodedContent['other'] ?? '')); + } + + /** + * @param array $customerNumbers + */ + private function primeCustomerContexts(array $customerNumbers): void + { + $missing = []; + foreach ($customerNumbers as $customerNumber) { + $normalized = (int)$customerNumber; + if ($normalized <= 0 || array_key_exists($normalized, $this->customerContextCache)) { + continue; + } + $missing[] = $normalized; + } + if (empty($missing)) { + return; + } + + $loaded = $this->loadCustomerContexts($missing); + foreach ($missing as $customerNumber) { + $this->customerContextCache[$customerNumber] = $loaded[$customerNumber] ?? null; + } + } + + /** + * @param array $customerNumbers + * @return array> + */ + protected function loadCustomerContexts(array $customerNumbers): array + { + $contexts = system_search_economic_customer_index::fetchContexts($customerNumbers); + foreach ($customerNumbers as $customerNumber) { + $normalized = (int)$customerNumber; + if ($normalized <= 0 || isset($contexts[$normalized])) { + continue; + } + + $fallback = $this->loadFallbackCustomerContext($normalized); + if ($fallback !== null) { + $contexts[$normalized] = $fallback; + } + } + + return $contexts; + } + + /** + * @return array|null + */ + private function loadFallbackCustomerContext(int $customerNumber): ?array + { + if ($customerNumber <= 0) { + return null; + } + + try { + $users = new \objects\users_o(); + $name = $users->getCustomerName($customerNumber); + $userId = null; + try { + $resolvedUserId = $users->getUserIdFromEconomic($customerNumber); + $userId = $resolvedUserId > 0 ? $resolvedUserId : null; + } catch (Throwable) { + $userId = null; + } + + $resolved = ($name !== null && trim($name) !== '') || $userId !== null; + $barred = $resolved ? $users->isCustomerBarred($customerNumber) : null; + + return [ + 'customer_number' => $customerNumber, + 'user_id' => $userId, + 'name' => is_string($name) && trim($name) !== '' ? trim($name) : null, + 'barred' => $barred, + 'status' => $this->customerBarredStatus($barred), + 'email' => null, + 'phone' => null, + 'cvr' => null, + 'address' => null, + 'city' => null, + 'zip' => null, + ]; + } catch (Throwable) { + return null; + } + } + + /** + * @return array|null + */ + private function customerContext(?int $customerNumber): ?array + { + if ($customerNumber === null || $customerNumber <= 0) { + return null; + } + $this->primeCustomerContexts([$customerNumber]); + return $this->customerContextCache[$customerNumber] ?? null; + } + + /** + * @param array $result + * @return array + */ + private function decorateSearchResultWithCustomerContext(array $result): array + { + $payload = is_array($result['payload'] ?? null) ? $result['payload'] : []; + $customerNumber = $this->toIntOrNull($result['customer_number'] ?? ($payload['customer_number'] ?? null)); + if ($customerNumber !== null) { + $result['customer_number'] = $customerNumber; + } + + $context = $this->customerContext($customerNumber); + $result['payload'] = $this->enrichPayloadWithCustomerContext($payload, $customerNumber, $context); + if ($context !== null) { + $result['customer_name'] = $context['name'] ?? null; + $result['customer_barred'] = $context['barred'] ?? null; + $result['customer_status'] = $context['status'] ?? $this->customerBarredStatus($context['barred'] ?? null); + $result['title'] = $this->overrideUnnamedUserTitleWithCustomerName($result, $context); + } + + return $result; + } + + /** + * @param array $result + * @param array $context + */ + private function overrideUnnamedUserTitleWithCustomerName(array $result, array $context): string + { + $title = trim((string)($result['title'] ?? '')); + if (trim(mb_strtolower((string)($result['entity_type'] ?? ''))) !== 'users') { + return $title; + } + if (trim(mb_strtolower($title)) !== 'unnamed') { + return $title; + } + + $customerName = trim((string)($context['name'] ?? '')); + return $customerName !== '' ? $customerName : $title; + } + + /** + * @param array $payload + * @param array|null $context + * @return array + */ + private function enrichPayloadWithCustomerContext(array $payload, ?int $customerNumber, ?array $context = null): array + { + if ($customerNumber !== null) { + $payload['customer_number'] = $customerNumber; + } + if ($context === null) { + $context = $this->customerContext($customerNumber); + } + if ($context === null) { + return $payload; + } + + $payload['customer_context'] = $context; + $payload['customer_name'] = $context['name'] ?? null; + $payload['customer_barred'] = $context['barred'] ?? null; + $payload['customer_status'] = $context['status'] ?? $this->customerBarredStatus($context['barred'] ?? null); + foreach (['email', 'phone', 'cvr', 'address', 'city', 'zip', 'user_id'] as $key) { + if (array_key_exists($key, $context)) { + $payload['customer_' . $key] = $context[$key]; + } + } + + return $payload; + } + + private function customerBarredStatus(?bool $barred): string + { + return match ($barred) { + true => 'barred', + false => 'active', + default => 'unknown', + }; + } + private function resultRecencyTimestamp(array $result): int { $timestamps = []; @@ -1775,13 +2609,7 @@ class system_search_service } // 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)) { + if ($this->queryHasExplicitIdentifier($normalized)) { return false; } return true; @@ -1842,11 +2670,67 @@ class system_search_service 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)) { + if ($this->queryHasExplicitIdentifier($normalized)) { + return false; + } + + $hasIntentVerb = preg_match('/\b(find|show|search|looking|need|want|where|which)\b/iu', $normalized) === 1; + $hasRelationalLanguage = preg_match('/\b(with|without|from|between|for|unpaid|overdue|rabat|discount|faktura|invoice|kunde|customer|orders?|vehicles?)\b/iu', $normalized) === 1; + $hasStrongDomainLanguage = preg_match('/\b(unpaid|overdue|rabat|discount|faktura|invoice)\b/iu', $normalized) === 1; + + if ($hasStrongDomainLanguage && count($terms) >= 2) { return true; } - return mb_strlen($normalized) >= 24 && count($terms) >= 3; + if ($hasIntentVerb && count($terms) >= 3) { + return true; + } + + if ($hasRelationalLanguage && count($terms) >= 3 && mb_strlen($normalized) >= 16) { + return true; + } + + return mb_strlen($normalized) >= 28 && count($terms) >= 4; + } + + private function queryHasExplicitIdentifier(string $normalizedQuery): bool + { + if ($normalizedQuery === '') { + return false; + } + if (str_contains($normalizedQuery, '@')) { + return true; + } + if (preg_match('/(?:^|[\s#])(order|invoice|booking|customer|kunde|vehicle|subuser|user)[\s:#-]*\d{3,}/iu', $normalizedQuery)) { + return true; + } + if (preg_match('/\b\d{5,}\b/', $normalizedQuery)) { + return true; + } + if (preg_match('/\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', $normalizedQuery)) { + return true; + } + return false; + } + + /** + * @param array $terms + */ + private function buildBooleanFullTextQuery(array $terms): ?string + { + $parts = []; + foreach ($terms as $term) { + $normalized = trim(mb_strtolower((string)$term)); + if ($normalized === '' || mb_strlen($normalized) < 3) { + return null; + } + $sanitized = preg_replace('/[^\p{L}\p{N}_]+/u', '', $normalized); + if (!is_string($sanitized) || $sanitized === '' || mb_strlen($sanitized) < 3) { + return null; + } + $parts[] = '+' . $sanitized . '*'; + } + return empty($parts) ? null : implode(' ', array_values(array_unique($parts))); } /** @@ -1891,7 +2775,7 @@ class system_search_service if ($query === '') { return []; } - $parts = preg_split('/[^a-z0-9_]+/iu', $query) ?: []; + $parts = preg_split('/[^\p{L}\p{N}_]+/u', $query) ?: []; $parts = array_values(array_filter(array_map('trim', $parts), static fn($p) => $p !== '' && mb_strlen($p) >= 2)); return array_values(array_unique($parts)); } @@ -1952,6 +2836,36 @@ class system_search_service ], JSON_UNESCAPED_UNICODE)); } + /** + * @param array $activeTypes + * @return array + */ + private function relevantSourceTables(array $activeTypes): array + { + $tables = []; + foreach ($activeTypes as $entityType) { + $tables = [...$tables, ...system_search_registry::sourceTablesForEntityType($entityType)]; + if ($this->entityTypeUsesCustomerContext($entityType)) { + $tables[] = system_search_economic_customer_index::TABLE; + } + } + return array_values(array_unique(array_filter($tables, static fn($table) => is_string($table) && $table !== ''))); + } + + private function entityTypeUsesCustomerContext(string $entityType): bool + { + $entityType = trim(mb_strtolower($entityType)); + if (in_array($entityType, ['objects', 'orders', 'order_items', 'invoices', 'vehicles', 'customers', 'employees', 'customer_discounts', 'customer_fixed_prices'], true)) { + return true; + } + + $config = $this->genericEntityConfigs()[$entityType] ?? null; + return is_array($config) + && isset($config['customer_field']) + && is_string($config['customer_field']) + && trim($config['customer_field']) !== ''; + } + private function normalizeTypes(array $types): array { $normalized = []; @@ -1970,59 +2884,12 @@ class system_search_service private function allEntityTypes(): array { - return array_values(array_unique([ - 'objects', - 'module_config', - 'orders', - 'order_items', - 'customers', - 'employees', - 'subusers', - 'customer_discounts', - 'customer_fixed_prices', - 'departments', - 'permissions', - 'roles', - 'invoices', - 'vehicles', - ...array_keys($this->genericEntityConfigs()), - ])); + return system_search_registry::allEntityTypes(); } private function taxonomy(array $activeTypes): array { - $aliases = [ - 'customers' => ['customer', 'account', 'company', 'kunde'], - 'orders' => ['order', 'work order'], - 'order_items' => ['order item', 'line item'], - 'invoices' => ['invoice', 'billing'], - 'vehicles' => ['vehicle', 'truck', 'plate'], - 'employees' => ['employee', 'staff'], - 'subusers' => ['subuser', 'driver'], - 'customer_discounts' => ['discount', 'price override', 'rabat'], - 'customer_fixed_prices' => ['fixed price', 'monthly agreement'], - 'departments' => ['department', 'location'], - 'permissions' => ['permission', 'acl'], - 'roles' => ['role', 'group'], - 'module_config' => ['module config', 'setting', 'configuration'], - 'objects' => ['attachment', 'object'], - 'bookings' => ['booking', 'wash booking'], - 'bookings_new' => ['new booking', 'booking queue'], - 'customer_notes' => ['customer note', 'note'], - 'order_bookings' => ['order booking', 'scheduled order'], - 'products' => ['product', 'service'], - 'product_options' => ['product option', 'addon', 'add on'], - 'plate_scans' => ['plate scan', 'license plate scan'], - 'plate_scanners' => ['plate scanner', 'license plate scanner'], - 'notifications' => ['notification', 'alert'], - 'users' => ['user', 'account user'], - 'module_action_logs' => ['module log', 'action log'], - 'motorapi_lookups' => ['motorapi lookup', 'plate lookup'], - 'xlvask_customers' => ['xlvask customer'], - 'xlvask_vehicles' => ['xlvask vehicle'], - 'xlvask_usage_logs' => ['xlvask usage log'], - 'department_daily_reports' => ['department daily report', 'daily report'], - ]; + $aliases = system_search_registry::taxonomyAliases(); $taxonomy = []; foreach ($activeTypes as $type) { $resolved = $aliases[$type] ?? []; diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index cb1bf401..2d2e114a 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -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()); diff --git a/services/nginx/app/tests/Integration/Search/SystemSearchCacheIntegrationTest.php b/services/nginx/app/tests/Integration/Search/SystemSearchCacheIntegrationTest.php index 36ca8737..aba87b38 100644 --- a/services/nginx/app/tests/Integration/Search/SystemSearchCacheIntegrationTest.php +++ b/services/nginx/app/tests/Integration/Search/SystemSearchCacheIntegrationTest.php @@ -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); }); diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchEntityTypeCoverageTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchEntityTypeCoverageTest.php index 41ec5325..f8993fee 100644 --- a/services/nginx/app/tests/Unit/Search/SystemSearchEntityTypeCoverageTest.php +++ b/services/nginx/app/tests/Unit/Search/SystemSearchEntityTypeCoverageTest.php @@ -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; diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchOpenAiIntentParserTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchOpenAiIntentParserTest.php index 5ca3afbf..293547a0 100644 --- a/services/nginx/app/tests/Unit/Search/SystemSearchOpenAiIntentParserTest.php +++ b/services/nginx/app/tests/Unit/Search/SystemSearchOpenAiIntentParserTest.php @@ -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 { diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php index da45e7a3..ca88406c 100644 --- a/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php +++ b/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php @@ -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> + */ + 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); +});