## Problem DRIFT 4: Customer tab search takes ~10s. Transaction history is similarly slow. ## Root cause `system_search_economic_customer_index` table (~50k+ rows) is searched with `LIKE '%term%'` queries. MySQL does a full table scan because there is no fulltext index. The wrapping code uses `LIKE` against a stringified row. ## Fix - New migration `2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index.php` creates a MySQL FULLTEXT index on the searchable columns. - `system_search_economic_customer_index.php` switched to `MATCH(cols) AGAINST (?)` when the index is present, with fallback to LIKE for older MySQL versions. - `system_search_service.php` updated to use the new fulltext query path. ## Investigation doc `documentation/perf/customer-search-slow-investigation.md` documents: - The exact SQL that was slow - EXPLAIN output - Table sizes - Why this is the bottleneck - Estimated impact after fix ## Tests `tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php` (437 lines) covers the new fulltext-backed search behavior. ## Estimated impact | Search type | Before | After | |-------------------|--------|--------| | Customer search | ~10s | <500ms | | Transaction hist. | ~10s | <500ms | Refs: TRU-62, TRU-4 (DRIFT 4) --------- Co-authored-by: TRU-198 Subagent <subagent@openhands.dev> Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io>
431 lines
16 KiB
PHP
431 lines
16 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use objects\users_o;
|
|
use Throwable;
|
|
|
|
class system_search_economic_customer_index
|
|
{
|
|
public const TABLE = 'system_search_economic_customer_index';
|
|
|
|
/**
|
|
* FULLTEXT key name used by TRU-62 customer-search performance fix.
|
|
* The column already exists (TEXT NULL `search_text`) — we just need
|
|
* the index. See documentation/perf/customer-search-slow-investigation.md.
|
|
*/
|
|
public const FULLTEXT_INDEX = 'ft_sseci_search_text';
|
|
|
|
private static bool $initialized = false;
|
|
|
|
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 . "` (
|
|
`customer_number` INT NOT NULL,
|
|
`user_id` INT NULL,
|
|
`local_display_name` VARCHAR(255) NULL,
|
|
`local_email` VARCHAR(255) NULL,
|
|
`local_phone` VARCHAR(64) NULL,
|
|
`economic_name` VARCHAR(255) NULL,
|
|
`economic_address` VARCHAR(255) NULL,
|
|
`economic_city` VARCHAR(255) NULL,
|
|
`economic_zip` VARCHAR(64) NULL,
|
|
`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`),
|
|
INDEX `idx_system_search_econ_customer_user` (`user_id`),
|
|
INDEX `idx_system_search_econ_customer_name` (`economic_name`),
|
|
INDEX `idx_system_search_econ_customer_email` (`economic_email`),
|
|
INDEX `idx_system_search_econ_customer_cvr` (`economic_cvr`)
|
|
) 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`"
|
|
);
|
|
// TRU-62: ensure the FULLTEXT index used by the customer-search fast
|
|
// path. Safe to call repeatedly: `ensureIndex` no-ops when the index
|
|
// already exists. The search code falls back to the LIKE-based query
|
|
// when this index is absent, so an incomplete migration is non-fatal.
|
|
self::ensureIndex(
|
|
self::FULLTEXT_INDEX,
|
|
"ALTER TABLE `" . self::TABLE . "` ADD FULLTEXT INDEX `" . self::FULLTEXT_INDEX . "` (`search_text`)"
|
|
);
|
|
self::$initialized = true;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, int> $customerNumbers
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public static function fetchContexts(array $customerNumbers): array
|
|
{
|
|
self::ensureTable();
|
|
|
|
$normalized = array_values(array_unique(array_filter(
|
|
array_map('intval', $customerNumbers),
|
|
static fn(int $value): bool => $value > 0
|
|
)));
|
|
if (empty($normalized)) {
|
|
return [];
|
|
}
|
|
|
|
global $db;
|
|
if (!is_object($db) || !method_exists($db, 'query')) {
|
|
return [];
|
|
}
|
|
|
|
$result = $db->query(
|
|
"SELECT `customer_number`, `user_id`, `local_display_name`, `local_email`, `local_phone`,"
|
|
. " `economic_name`, `economic_address`, `economic_city`, `economic_zip`, `economic_email`,"
|
|
. " `economic_cvr`, `economic_mobile_phone`, `economic_barred`"
|
|
. " FROM `" . self::TABLE . "`"
|
|
. " WHERE `customer_number` IN (" . implode(',', $normalized) . ")"
|
|
);
|
|
if (!($result instanceof \mysqli_result)) {
|
|
return [];
|
|
}
|
|
|
|
$contexts = [];
|
|
while ($row = $result->fetch_assoc()) {
|
|
$customerNumber = (int)($row['customer_number'] ?? 0);
|
|
if ($customerNumber <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$barred = self::toNullableBool($row['economic_barred'] ?? null);
|
|
$contexts[$customerNumber] = [
|
|
'customer_number' => $customerNumber,
|
|
'user_id' => self::toNullableInt($row['user_id'] ?? null),
|
|
'name' => self::toNullableString($row['economic_name'] ?? null)
|
|
?? self::toNullableString($row['local_display_name'] ?? null),
|
|
'barred' => $barred,
|
|
'status' => self::barredStatus($barred),
|
|
'email' => self::toNullableString($row['economic_email'] ?? null)
|
|
?? self::toNullableString($row['local_email'] ?? null),
|
|
'phone' => self::toNullableString($row['economic_mobile_phone'] ?? null)
|
|
?? self::toNullableString($row['local_phone'] ?? null),
|
|
'cvr' => self::toNullableString($row['economic_cvr'] ?? null),
|
|
'address' => self::toNullableString($row['economic_address'] ?? null),
|
|
'city' => self::toNullableString($row['economic_city'] ?? null),
|
|
'zip' => self::toNullableString($row['economic_zip'] ?? null),
|
|
];
|
|
}
|
|
|
|
return $contexts;
|
|
}
|
|
|
|
/**
|
|
* Rebuild local e-conomic customer index from local users + cached/live e-conomic snapshots.
|
|
*
|
|
* @return array<string, int>
|
|
*/
|
|
public static function refreshIndex(bool $refreshEconomicData = false): array
|
|
{
|
|
self::ensureTable();
|
|
|
|
global $db;
|
|
if (!is_object($db) || !method_exists($db, 'query') || !property_exists($db, 'conn')) {
|
|
return [
|
|
'processed' => 0,
|
|
'upserted' => 0,
|
|
'deleted' => 0,
|
|
'errors' => 0,
|
|
];
|
|
}
|
|
|
|
$stats = [
|
|
'processed' => 0,
|
|
'upserted' => 0,
|
|
'deleted' => 0,
|
|
'errors' => 0,
|
|
];
|
|
|
|
$result = $db->query("SELECT `id`, `customer_number`, `display_name`, `email`, `phone`
|
|
FROM `users`
|
|
WHERE `customer_number` IS NOT NULL
|
|
AND `customer_number` <> 0");
|
|
if (!($result instanceof \mysqli_result)) {
|
|
return $stats;
|
|
}
|
|
|
|
$rows = $db->fetch_all($result);
|
|
$seenCustomerNumbers = [];
|
|
|
|
foreach ($rows as $row) {
|
|
$stats['processed']++;
|
|
|
|
$customerNumber = (int)($row['customer_number'] ?? 0);
|
|
if ($customerNumber <= 0) {
|
|
continue;
|
|
}
|
|
$seenCustomerNumbers[$customerNumber] = true;
|
|
|
|
$economic = [];
|
|
try {
|
|
$tmpUser = new users_o();
|
|
$tmpUser->getUserByCustomerNumber($customerNumber);
|
|
if ($refreshEconomicData) {
|
|
$tmpUser->getCustomerEcocomicData($customerNumber);
|
|
}
|
|
$cached = $tmpUser->getCached('economic_customer');
|
|
if (!$cached && !$refreshEconomicData) {
|
|
$tmpUser->getCustomerEcocomicData($customerNumber);
|
|
$cached = $tmpUser->getCached('economic_customer');
|
|
}
|
|
$economic = self::normalizeEconomicSnapshot($cached);
|
|
} catch (Throwable) {
|
|
$stats['errors']++;
|
|
}
|
|
|
|
$localDisplayName = self::toNullableString($row['display_name'] ?? null);
|
|
$localEmail = self::toNullableString($row['email'] ?? null);
|
|
$localPhone = self::toNullableString($row['phone'] ?? null);
|
|
|
|
$economicName = self::toNullableString($economic['name'] ?? null);
|
|
$economicAddress = self::toNullableString($economic['address'] ?? null);
|
|
$economicCity = self::toNullableString($economic['city'] ?? null);
|
|
$economicZip = self::toNullableString($economic['zip'] ?? null);
|
|
$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,
|
|
$localDisplayName,
|
|
$localEmail,
|
|
$localPhone,
|
|
$economicName,
|
|
$economicAddress,
|
|
$economicCity,
|
|
$economicZip,
|
|
$economicEmail,
|
|
$economicCvr,
|
|
$economicMobilePhone,
|
|
], static fn($v) => is_string($v) && trim($v) !== ''))));
|
|
if ($searchText === '') {
|
|
$searchText = null;
|
|
}
|
|
|
|
$sql = "INSERT INTO `" . self::TABLE . "` (
|
|
`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`,
|
|
`search_text`
|
|
) VALUES (
|
|
" . (int)$customerNumber . ",
|
|
" . (int)($row['id'] ?? 0) . ",
|
|
" . self::sqlNullableString($localDisplayName) . ",
|
|
" . self::sqlNullableString($localEmail) . ",
|
|
" . self::sqlNullableString($localPhone) . ",
|
|
" . self::sqlNullableString($economicName) . ",
|
|
" . self::sqlNullableString($economicAddress) . ",
|
|
" . self::sqlNullableString($economicCity) . ",
|
|
" . self::sqlNullableString($economicZip) . ",
|
|
" . self::sqlNullableString($economicEmail) . ",
|
|
" . self::sqlNullableString($economicCvr) . ",
|
|
" . self::sqlNullableString($economicMobilePhone) . ",
|
|
" . self::sqlNullableBool($economicBarred) . ",
|
|
" . self::sqlNullableString($searchText) . "
|
|
) ON DUPLICATE KEY UPDATE
|
|
`user_id` = VALUES(`user_id`),
|
|
`local_display_name` = VALUES(`local_display_name`),
|
|
`local_email` = VALUES(`local_email`),
|
|
`local_phone` = VALUES(`local_phone`),
|
|
`economic_name` = VALUES(`economic_name`),
|
|
`economic_address` = VALUES(`economic_address`),
|
|
`economic_city` = VALUES(`economic_city`),
|
|
`economic_zip` = VALUES(`economic_zip`),
|
|
`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);
|
|
$stats['upserted']++;
|
|
}
|
|
|
|
$seen = array_keys($seenCustomerNumbers);
|
|
if (empty($seen)) {
|
|
$db->query("DELETE FROM `" . self::TABLE . "`");
|
|
$stats['deleted'] = self::safeAffectedRows();
|
|
return $stats;
|
|
}
|
|
|
|
$in = implode(',', array_map('intval', $seen));
|
|
$db->query("DELETE FROM `" . self::TABLE . "` WHERE `customer_number` NOT IN (" . $in . ")");
|
|
$stats['deleted'] = self::safeAffectedRows();
|
|
|
|
return $stats;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function normalizeEconomicSnapshot(mixed $snapshot): array
|
|
{
|
|
if (is_object($snapshot)) {
|
|
return get_object_vars($snapshot);
|
|
}
|
|
if (is_array($snapshot)) {
|
|
return $snapshot;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
private static function toNullableString(mixed $value): ?string
|
|
{
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
$string = trim((string)$value);
|
|
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;
|
|
if ($value === null) {
|
|
return 'NULL';
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure an index (FULLTEXT or otherwise) exists on the table.
|
|
* No-ops when the index is already present so this is safe to call
|
|
* repeatedly at request time.
|
|
*/
|
|
private static function ensureIndex(string $indexName, string $alterSql): void
|
|
{
|
|
global $db;
|
|
if (!is_object($db) || !method_exists($db, 'query')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$result = $db->query(
|
|
"SHOW INDEX FROM `" . self::TABLE . "` WHERE `Key_name` = '"
|
|
. $db->escape_string($indexName) . "'"
|
|
);
|
|
} catch (Throwable) {
|
|
return;
|
|
}
|
|
if ($result instanceof \mysqli_result && $result->num_rows === 0) {
|
|
try {
|
|
$db->query($alterSql);
|
|
} catch (Throwable $e) {
|
|
// The search code falls back to the LIKE path when the
|
|
// index is missing, so a failed ALTER is non-fatal.
|
|
if (function_exists('error_log')) {
|
|
@error_log('[system_search_economic_customer_index] failed to add index ' . $indexName . ': ' . $e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static function barredStatus(?bool $barred): string
|
|
{
|
|
return match ($barred) {
|
|
true => 'barred',
|
|
false => 'active',
|
|
default => 'unknown',
|
|
};
|
|
}
|
|
|
|
private static function safeAffectedRows(): int
|
|
{
|
|
global $db;
|
|
if (!is_object($db) || !property_exists($db, 'conn') || !is_object($db->conn)) {
|
|
return 0;
|
|
}
|
|
return max(0, (int)($db->conn->affected_rows ?? 0));
|
|
}
|
|
}
|