Files
api/services/nginx/app/classes/system_search_document_index.php
T

1408 lines
53 KiB
PHP

<?php
namespace classes;
use Throwable;
class system_search_document_index
{
public const TABLE = 'system_search_documents';
private static bool $initialized = false;
/**
* @var array<string, array<int, string>>
*/
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<int, string> $types
* @return array<string, int>
*/
public static function refreshIndex(array $types = []): array
{
$instance = new self();
return $instance->refresh($types);
}
/**
* @param array<int, string> $types
* @return array<string, int>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
private function buildCustomerDiscountDocuments(): array
{
price_overrides_schema_bootstrap::ensureColumns();
$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',
'po.fixed_price',
'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['fixed_price'] ?? 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),
'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null),
'economic_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null,
'is_category' => $row['is_category'] ?? null,
],
$row
);
}
return $documents;
}
/**
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, string> $candidateFields
* @param array<int, string> $searchFields
* @param array<int, string> $titleFields
* @param array<int, string> $descriptionFields
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $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<string, mixed> $payload
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
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<int, string> $fields
* @return array<int, string>
*/
private function appendTemporalColumns(string $table, array $fields): array
{
$columns = $this->getColumns($table);
foreach (['updated_at', 'created_at'] as $column) {
if (in_array($column, $columns, true) && !in_array($column, $fields, true)) {
$fields[] = $column;
}
}
return array_values(array_unique($fields));
}
/**
* @return array<int, string>
*/
private function joinTemporalSelectFields(string $table, string $alias): array
{
$fields = [];
$columns = $this->getColumns($table);
$aliasPrefix = trim($alias) === '' ? '' : (trim($alias) . '.');
foreach (['updated_at', 'created_at'] as $column) {
if (!in_array($column, $columns, true)) {
continue;
}
$fields[] = $aliasPrefix . $column . ' AS ' . $column;
}
return $fields;
}
/**
* @param array<int, mixed> $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<int, string> $columns
* @param array<int, string> $excludeColumns
* @return array<int, string>
*/
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<int, array<string, mixed>>
*/
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<int, string> $candidateFields
* @return array<int, string>
*/
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<int, string>
*/
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'] ?? ''));
}
}