557 lines
18 KiB
PHP
557 lines
18 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use PDO;
|
|
|
|
class order_reference_suggestions_service
|
|
{
|
|
private const DEFAULT_LIMIT = 10;
|
|
private const MAX_LIMIT = 25;
|
|
private const MAX_SOURCE_ROWS = 500;
|
|
|
|
/**
|
|
* @var array<string, bool>
|
|
*/
|
|
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<int, array<string, mixed>>
|
|
*/
|
|
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<int, array<string, mixed>>
|
|
*/
|
|
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<int, array<string, mixed>>
|
|
*/
|
|
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<int, string> $plates
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
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<string, mixed> $params
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
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<int, array<string, mixed>> $rows
|
|
* @param array<int, string> $plates
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
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<int, array<string, mixed>> $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<int, string> $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<int, string> $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<int, string> $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<int, mixed> $values
|
|
* @return array<int, string>
|
|
*/
|
|
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;
|
|
}
|
|
}
|