Add unit tests for InvoicingPeriodDraftOverlay and reference suggestion logic, including fake DB integration and aggregation methods
- Implemented `InvoicingPeriodDraftOverlayTest` with coverage for blocking and permitting invoicing actions based on draft states, transactions, and metadata. - Created `ReferenceSuggestionsApiTest` to validate ranked and filtered suggestions across bookings, orders, and vehicles with varied match relevance, context, and frequency. - Added `order_reference_suggestions_service` class, including query methods, normalization utilities, and aggregation logic for reference suggestions. - Enhanced query handling in `InvoicingPeriodDraftOverlayFakeDb` to validate SQL constraints and column cache resets in overlapping invoicing contexts.
This commit is contained in:
@@ -0,0 +1,517 @@
|
||||
<?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($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 $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) . '%';
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user