> */ 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 ($entityType === 'department_goals') { $title = $this->departmentGoalResultTitle($row['criteria'] ?? null, $entityId, $title); } 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; } private function departmentGoalResultTitle(mixed $criteria, mixed $entityId = null, string $fallback = ''): string { $label = $this->departmentGoalLabelFromCriteria($criteria); if ($label !== '') { return $label; } $trimmedFallback = trim($fallback); if ($trimmedFallback !== '') { return $trimmedFallback; } $resolvedId = $this->toIntOrNull($entityId); return $resolvedId !== null && $resolvedId > 0 ? 'Department goal #' . $resolvedId : 'Department goal'; } private function departmentGoalLabelFromCriteria(mixed $criteria): string { $decoded = null; if (is_array($criteria)) { $decoded = $criteria; } elseif (is_string($criteria) && trim($criteria) !== '') { $decodedValue = json_decode($criteria, true); if (is_array($decodedValue)) { $decoded = $decodedValue; } } if (!is_array($decoded)) { return ''; } return trim((string)($decoded['label'] ?? '')); } }