*/ private array $columnExistsCache = []; /** * @param array{ * search?: mixed, * department_id?: mixed, * customer_id?: mixed, * reg_1?: mixed, * reg_2?: mixed, * reg_3?: mixed, * limit?: mixed * } $criteria * @return array> */ public function suggest(array $criteria): array { $departmentId = $this->toPositiveInt($criteria['department_id'] ?? null); if ($departmentId === null) { return []; } $search = $this->normalizeText($criteria['search'] ?? ''); $customerId = $this->toPositiveInt($criteria['customer_id'] ?? null); $plates = $this->normalizePlates([ $criteria['reg_1'] ?? '', $criteria['reg_2'] ?? '', $criteria['reg_3'] ?? '', ]); $limit = $this->clampLimit($criteria['limit'] ?? self::DEFAULT_LIMIT); $rows = [ ...$this->fetchBookingRows($departmentId, $search), ...$this->fetchOrderRows($departmentId, $search), ...$this->fetchVehicleRows($departmentId, $customerId, $plates, $search), ]; $suggestions = $this->aggregateRows($rows, $search, $customerId, $plates); usort($suggestions, [$this, 'sortSuggestions']); return array_slice($suggestions, 0, $limit); } /** * @return array> */ private function fetchBookingRows(int $departmentId, string $search): array { $where = [ 'department = :department_id', 'reference IS NOT NULL', "TRIM(reference) <> ''", ]; if ($this->tableHasColumn('order_bookings', 'deleted_at')) { array_unshift($where, 'deleted_at IS NULL'); } $params = ['department_id' => $departmentId]; if ($search !== '') { $where[] = 'LOWER(reference) LIKE :search'; $params['search'] = '%' . $this->lower($search) . '%'; } $sql = "SELECT 'booking' AS source, id AS origin_id, TRIM(reference) AS reference, datetime AS source_created_at, datetime AS used_at, customer_number AS customer_id, department AS department_id, reg_1, reg_2, reg_3 FROM order_bookings WHERE " . implode(' AND ', $where) . " ORDER BY datetime DESC, id DESC LIMIT :source_limit"; return $this->fetchRows($sql, $params); } /** * @return array> */ private function fetchOrderRows(int $departmentId, string $search): array { $where = [ 'department_id = :department_id', 'reference IS NOT NULL', "TRIM(reference) <> ''", ]; if ($this->tableHasColumn('orders', 'deleted_at')) { array_unshift($where, 'deleted_at IS NULL'); } $params = ['department_id' => $departmentId]; if ($search !== '') { $where[] = 'LOWER(reference) LIKE :search'; $params['search'] = '%' . $this->lower($search) . '%'; } $sql = "SELECT 'order' AS source, id AS origin_id, TRIM(reference) AS reference, created_at AS source_created_at, created_at AS used_at, customer_id, department_id, reg_1, reg_2, reg_3 FROM orders WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC, id DESC LIMIT :source_limit"; return $this->fetchRows($sql, $params); } /** * @param array $plates * @return array> */ private function fetchVehicleRows(int $departmentId, ?int $customerId, array $plates, string $search): array { $contextWhere = []; $params = []; if ($customerId !== null) { $contextWhere[] = 'customer_id = :customer_id'; $params['customer_id'] = $customerId; } foreach ($plates as $index => $plate) { $key = 'plate_' . $index; $contextWhere[] = "UPPER(REPLACE(reg, ' ', '')) = :$key"; $params[$key] = $plate; } if ($contextWhere === []) { return []; } $where = [ 'reference IS NOT NULL', "TRIM(reference) <> ''", '(' . implode(' OR ', $contextWhere) . ')', ]; if ($this->tableHasColumn('customer_vehicles', 'deleted_at')) { array_unshift($where, 'deleted_at IS NULL'); } if ($search !== '') { $where[] = 'LOWER(reference) LIKE :search'; $params['search'] = '%' . $this->lower($search) . '%'; } $where[] = $this->vehicleDepartmentAccessPredicate(); $params['orders_department_id'] = $departmentId; $params['bookings_department_id'] = $departmentId; $sql = "SELECT 'vehicle' AS source, id AS origin_id, TRIM(reference) AS reference, created_at AS source_created_at, created_at AS used_at, customer_id, NULL AS department_id, reg AS reg_1, '' AS reg_2, '' AS reg_3 FROM customer_vehicles WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC, id DESC LIMIT :source_limit"; return $this->fetchRows($sql, $params); } private function vehicleDepartmentAccessPredicate(): string { $ordersWhere = [ 'authorized_orders.department_id = :orders_department_id', '(authorized_orders.customer_id = customer_vehicles.customer_id' . " OR UPPER(REPLACE(authorized_orders.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))" . " OR UPPER(REPLACE(authorized_orders.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))" . " OR UPPER(REPLACE(authorized_orders.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))" ]; if ($this->tableHasColumn('orders', 'deleted_at')) { $ordersWhere[] = 'authorized_orders.deleted_at IS NULL'; } $bookingsWhere = [ 'authorized_bookings.department = :bookings_department_id', '(authorized_bookings.customer_number = customer_vehicles.customer_id' . " OR UPPER(REPLACE(authorized_bookings.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))" . " OR UPPER(REPLACE(authorized_bookings.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))" . " OR UPPER(REPLACE(authorized_bookings.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))" ]; if ($this->tableHasColumn('order_bookings', 'deleted_at')) { $bookingsWhere[] = 'authorized_bookings.deleted_at IS NULL'; } return '(EXISTS ( SELECT 1 FROM orders authorized_orders WHERE ' . implode(' AND ', $ordersWhere) . ' ) OR EXISTS ( SELECT 1 FROM order_bookings authorized_bookings WHERE ' . implode(' AND ', $bookingsWhere) . ' ))'; } /** * @param array $params * @return array> */ private function fetchRows(string $sql, array $params): array { $pdo = db::getPDO(); $statement = $pdo->prepare($sql); foreach ($params as $key => $value) { $statement->bindValue(':' . $key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR); } $statement->bindValue(':source_limit', self::MAX_SOURCE_ROWS, PDO::PARAM_INT); $statement->execute(); $rows = $statement->fetchAll(PDO::FETCH_ASSOC); return is_array($rows) ? $rows : []; } private function tableHasColumn(string $table, string $column): bool { $cacheKey = $table . '.' . $column; if (array_key_exists($cacheKey, $this->columnExistsCache)) { return $this->columnExistsCache[$cacheKey]; } $pdo = db::getPDO(); $statement = $pdo->prepare( 'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table_name AND COLUMN_NAME = :column_name' ); $statement->bindValue(':table_name', $table, PDO::PARAM_STR); $statement->bindValue(':column_name', $column, PDO::PARAM_STR); $statement->execute(); $this->columnExistsCache[$cacheKey] = ((int)$statement->fetchColumn()) > 0; return $this->columnExistsCache[$cacheKey]; } /** * @param array> $rows * @param array $plates * @return array> */ private function aggregateRows(array $rows, string $search, ?int $customerId, array $plates): array { $groups = []; foreach ($rows as $row) { $reference = $this->normalizeText($row['reference'] ?? ''); if ($reference === '') { continue; } $key = $this->lower($reference); if (!isset($groups[$key])) { $groups[$key] = [ 'reference' => $reference, 'rows' => [], 'usage_count' => 0, 'last_used_at' => null, 'context_boost' => 0, 'section' => 'other', ]; } $section = $this->contextSection($row, $customerId, $plates); $groups[$key]['usage_count']++; $groups[$key]['rows'][] = $row; $groups[$key]['last_used_at'] = $this->maxDate( $groups[$key]['last_used_at'], $this->normalizeDate($row['used_at'] ?? null) ); $groups[$key]['context_boost'] = max( $groups[$key]['context_boost'], $this->contextBoost($row, $customerId, $plates) ); $groups[$key]['section'] = $this->bestSection( (string)$groups[$key]['section'], $section ); } $suggestions = []; foreach ($groups as $group) { $bestRow = $this->bestOriginRow($group['rows']); if ($bestRow === null) { continue; } $source = (string)($bestRow['source'] ?? 'order'); $usageCount = (int)$group['usage_count']; $score = $this->matchScore((string)$group['reference'], $search) + (int)$group['context_boost'] + $this->sectionScore((string)$group['section']) + $this->sourceScore($source) + min($usageCount, 20) * 5; $suggestions[] = [ 'source' => $source, 'section' => (string)$group['section'], 'reference' => (string)$group['reference'], 'source_created_at' => $this->normalizeDate($bestRow['source_created_at'] ?? null), 'last_used_at' => $group['last_used_at'], 'usage_count' => $usageCount, 'origin_id' => (int)($bestRow['origin_id'] ?? 0), 'score' => $score, ]; } return $suggestions; } /** * @param array> $rows */ private function bestOriginRow(array $rows): ?array { usort($rows, function (array $left, array $right): int { $sourceCompare = $this->sourceScore((string)($right['source'] ?? '')) <=> $this->sourceScore((string)($left['source'] ?? '')); if ($sourceCompare !== 0) { return $sourceCompare; } $dateCompare = strcmp( (string)$this->normalizeDate($right['source_created_at'] ?? null), (string)$this->normalizeDate($left['source_created_at'] ?? null) ); if ($dateCompare !== 0) { return $dateCompare; } return ((int)($right['origin_id'] ?? 0)) <=> ((int)($left['origin_id'] ?? 0)); }); return $rows[0] ?? null; } private function sortSuggestions(array $left, array $right): int { $scoreCompare = ((int)($right['score'] ?? 0)) <=> ((int)($left['score'] ?? 0)); if ($scoreCompare !== 0) { return $scoreCompare; } $usageCompare = ((int)($right['usage_count'] ?? 0)) <=> ((int)($left['usage_count'] ?? 0)); if ($usageCompare !== 0) { return $usageCompare; } $sectionCompare = $this->sectionScore((string)($right['section'] ?? '')) <=> $this->sectionScore((string)($left['section'] ?? '')); if ($sectionCompare !== 0) { return $sectionCompare; } $dateCompare = strcmp((string)($right['last_used_at'] ?? ''), (string)($left['last_used_at'] ?? '')); if ($dateCompare !== 0) { return $dateCompare; } $referenceCompare = strcmp((string)($left['reference'] ?? ''), (string)($right['reference'] ?? '')); if ($referenceCompare !== 0) { return $referenceCompare; } return $this->sourceScore((string)($right['source'] ?? '')) <=> $this->sourceScore((string)($left['source'] ?? '')); } /** * @param array $plates */ private function contextBoost(array $row, ?int $customerId, array $plates): int { $score = 0; if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) { $score += 80; } if ($this->rowMatchesAnyPlate($row, $plates)) { $score += 90; } return $score; } /** * @param array $plates */ private function contextSection(array $row, ?int $customerId, array $plates): string { if ($this->rowMatchesAnyPlate($row, $plates)) { return 'this_vehicle'; } if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) { return 'other_customer_vehicle'; } return 'other'; } /** * @param array $plates */ private function rowMatchesAnyPlate(array $row, array $plates): bool { $rowPlates = $this->normalizePlates([ $row['reg_1'] ?? '', $row['reg_2'] ?? '', $row['reg_3'] ?? '', ]); return $plates !== [] && array_intersect($plates, $rowPlates) !== []; } private function matchScore(string $reference, string $search): int { if ($search === '') { return 0; } $referenceKey = $this->lower($reference); $searchKey = $this->lower($search); if ($referenceKey === $searchKey) { return 1000; } if (str_starts_with($referenceKey, $searchKey)) { return 600; } if (str_contains($referenceKey, $searchKey)) { return 300; } return 0; } private function sourceScore(string $source): int { return match ($source) { 'booking' => 30, 'order' => 20, 'vehicle' => 10, default => 0, }; } private function sectionScore(string $section): int { return match ($section) { 'this_vehicle' => 40, 'other_customer_vehicle' => 20, default => 0, }; } private function bestSection(string $left, string $right): string { return $this->sectionScore($right) > $this->sectionScore($left) ? $right : $left; } private function clampLimit(mixed $value): int { $limit = $this->toPositiveInt($value) ?? self::DEFAULT_LIMIT; return max(1, min($limit, self::MAX_LIMIT)); } private function toPositiveInt(mixed $value): ?int { $parsed = filter_var($value, FILTER_VALIDATE_INT); return is_int($parsed) && $parsed > 0 ? $parsed : null; } private function normalizeText(mixed $value): string { return trim((string)($value ?? '')); } private function lower(string $value): string { return function_exists('mb_strtolower') ? mb_strtolower($value) : strtolower($value); } /** * @param array $values * @return array */ private function normalizePlates(array $values): array { $plates = []; foreach ($values as $value) { $plate = strtoupper(preg_replace('/\s+/', '', (string)($value ?? ''))); if ($plate !== '') { $plates[] = $plate; } } return array_values(array_unique($plates)); } private function normalizeDate(mixed $value): ?string { $date = trim((string)($value ?? '')); return $date === '' || $date === '0000-00-00 00:00:00' ? null : $date; } private function maxDate(?string $left, ?string $right): ?string { if ($left === null) { return $right; } if ($right === null) { return $left; } return strcmp($right, $left) > 0 ? $right : $left; } }