2026-06-14 21:32:07 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
|
|
use Illuminate\Contracts\Database\Query\Builder;
|
|
|
|
|
use Illuminate\Contracts\Session\Session;
|
|
|
|
|
use Illuminate\Http\Request;
|
|
|
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
|
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
|
use Shared\Enums\BaseEnum;
|
feat(employees): automated sync with Arcadia API, BO settings, Shopper value objects, and pagination migration
- Implement automated synchronization of employees via Arcadia API (EmployeeSyncService, SynchronizeEmployeesWithArcadiaApi, FetchAndUpsertArcadiaEmployees)
- Add Back-Office application settings for employee sites (Setting singleton, GetSettingsQuery, UpdateSettings, SettingsController, EmployeeSitesInputComponent.vue)
- Strongly type Employee attributes using Domain Value Objects (ShopperEstablishmentCode, ShopperSiteCode, ShopperServiceCode) and Eloquent Casts
- Migrate pagination from Domain to Shared\Pagination namespace across queries and inputs
- Remove legacy and dead CSV employee import/export code
- Restore Domain\Ports\Services\CsvGenerator interface for Regate order generation
- Add comprehensive Pest unit, integration, and feature test coverage
2026-10-01 17:26:30 +02:00
|
|
|
use Shared\Pagination\Pagination;
|
2026-06-14 21:32:07 +02:00
|
|
|
use SortDirection;
|
|
|
|
|
|
|
|
|
|
class DataTableService
|
|
|
|
|
{
|
|
|
|
|
private const string ORDER_COLUMN = 'order';
|
|
|
|
|
|
|
|
|
|
private const string RESET_SORT_KEY = 'rst';
|
|
|
|
|
|
|
|
|
|
private const string CACHE_SORTED_KEY = 'sorted';
|
|
|
|
|
|
|
|
|
|
private const string CACHE_SORT_COL_KEY = 'sort_col';
|
|
|
|
|
|
|
|
|
|
private const string CACHE_SORT_WAY_KEY = 'sort_way';
|
|
|
|
|
|
|
|
|
|
public function __construct(
|
|
|
|
|
protected readonly Request $request,
|
|
|
|
|
protected readonly Session $session,
|
|
|
|
|
) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build Search query on specified fields
|
|
|
|
|
* splitting search into words or using whole sentence
|
|
|
|
|
*
|
|
|
|
|
* @template TModel of \Illuminate\Database\Eloquent\Model
|
|
|
|
|
* @template TBuilder of \Illuminate\Contracts\Database\Query\Builder
|
|
|
|
|
*
|
|
|
|
|
* @param class-string<TModel> $modelClass
|
|
|
|
|
* @param TBuilder $query
|
|
|
|
|
* @param (\Closure(TBuilder):(void|TBuilder))|null $searchQuery
|
|
|
|
|
*/
|
|
|
|
|
public function searchQuery(
|
|
|
|
|
string $modelClass,
|
|
|
|
|
Builder $query,
|
|
|
|
|
string $search,
|
|
|
|
|
?\Closure $searchQuery = null,
|
|
|
|
|
string ...$fields
|
|
|
|
|
): Builder {
|
|
|
|
|
if (empty($fields)) {
|
|
|
|
|
return $query;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$words = array_filter(explode(' ', $search));
|
|
|
|
|
|
|
|
|
|
$query->where(function ($q) use ($modelClass, $searchQuery, $words, $search, $fields): void {
|
|
|
|
|
// * Searching words
|
|
|
|
|
foreach ($words as $word) {
|
|
|
|
|
$this->searchOnFields($modelClass, $q, $word, ...$fields);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// * Searching whole sentence
|
|
|
|
|
$this->searchOnFields($modelClass, $q, $search, ...$fields);
|
|
|
|
|
|
|
|
|
|
if ($searchQuery) {
|
|
|
|
|
$searchQuery($q);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return $query;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 11:14:49 +02:00
|
|
|
/** @var array<string, array<int, string>> */
|
|
|
|
|
private static array $columnsCache = [];
|
|
|
|
|
|
|
|
|
|
public static function clearColumnsCache(): void
|
|
|
|
|
{
|
|
|
|
|
self::$columnsCache = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function hasTableColumn(string $table, string $column): bool
|
|
|
|
|
{
|
|
|
|
|
if (! isset(self::$columnsCache[$table])) {
|
|
|
|
|
self::$columnsCache[$table] = Schema::getColumnListing($table);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return in_array($column, self::$columnsCache[$table], true);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 21:32:07 +02:00
|
|
|
/**
|
|
|
|
|
* Sort columns with a query
|
|
|
|
|
*
|
|
|
|
|
* @template TModel of \Illuminate\Database\Eloquent\Model
|
|
|
|
|
* @template TBuilder of \Illuminate\Contracts\Database\Query\Builder
|
|
|
|
|
*
|
|
|
|
|
* @param class-string<TModel> $modelClass
|
|
|
|
|
* @param TBuilder $query
|
|
|
|
|
* @param array<int, string> $ignore Columns to ignore for sorting.
|
2026-10-29 21:16:06 +01:00
|
|
|
* @param array<string, string> $columnMap Map virtual column names to database columns.
|
2026-06-14 21:32:07 +02:00
|
|
|
*/
|
|
|
|
|
public function sortQuery(
|
|
|
|
|
string $modelClass,
|
|
|
|
|
Builder $query,
|
2026-10-29 21:16:06 +01:00
|
|
|
array $ignore = [],
|
|
|
|
|
?string $defaultSortCol = null,
|
|
|
|
|
SortDirection $defaultSortWay = SortDirection::Ascending,
|
|
|
|
|
array $columnMap = [],
|
2026-06-14 21:32:07 +02:00
|
|
|
): Builder {
|
|
|
|
|
$table = (new $modelClass())->getTable();
|
|
|
|
|
$rName = $this->request->route()?->getName() ?? 'noroute';
|
|
|
|
|
|
|
|
|
|
if ($this->request->has(self::RESET_SORT_KEY)) {
|
|
|
|
|
$this->resetSortSession($rName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$sort = $this->getSortRequest();
|
|
|
|
|
$sort_col = $sort['sort_col'];
|
|
|
|
|
$sort_way = $sort['sort_way'];
|
|
|
|
|
|
2026-10-29 21:16:06 +01:00
|
|
|
if ((! $sort_col || $sort_col === 'undefined') && $defaultSortCol !== null) {
|
|
|
|
|
$sort_col = $defaultSortCol;
|
|
|
|
|
$sort_way = $defaultSortWay;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 21:32:07 +02:00
|
|
|
$this->persistSortSession($rName, $sort_col, $sort_way);
|
|
|
|
|
|
2026-10-29 21:16:06 +01:00
|
|
|
$dbCol = $columnMap[$sort_col] ?? $sort_col;
|
|
|
|
|
|
|
|
|
|
if ($sort_col && $sort_col !== 'undefined' && $sort_way && ! in_array($sort_col, $ignore) && $this->hasTableColumn($table, $dbCol)) {
|
2026-06-14 21:32:07 +02:00
|
|
|
$direction = match ($sort_way) {
|
|
|
|
|
SortDirection::Descending => 'desc',
|
|
|
|
|
default => 'asc',
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-29 21:16:06 +01:00
|
|
|
$query->orderBy("{$table}.{$dbCol}", $direction);
|
2026-08-26 11:14:49 +02:00
|
|
|
} elseif ($this->hasTableColumn($table, self::ORDER_COLUMN)) {
|
2026-06-14 21:32:07 +02:00
|
|
|
$query->orderBy(self::ORDER_COLUMN, 'asc');
|
|
|
|
|
|
|
|
|
|
$this->persistSortSession(
|
|
|
|
|
$rName,
|
|
|
|
|
self::ORDER_COLUMN,
|
|
|
|
|
SortDirection::Ascending
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $query;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @template TModel of \Illuminate\Database\Eloquent\Model
|
|
|
|
|
*
|
|
|
|
|
* @param class-string<TModel> $modelClass
|
|
|
|
|
*/
|
|
|
|
|
private function searchOnFields(
|
|
|
|
|
string $modelClass,
|
|
|
|
|
Builder $query,
|
|
|
|
|
string $search,
|
|
|
|
|
string ...$fields
|
|
|
|
|
): Builder {
|
|
|
|
|
$table = (new $modelClass())->getTable();
|
|
|
|
|
$searchTerm = "%{$search}%";
|
|
|
|
|
|
|
|
|
|
foreach ($fields as $field) {
|
|
|
|
|
$field = str_replace('?', '\?', $field);
|
|
|
|
|
|
|
|
|
|
$searchValue = $this->getSearchValue(
|
|
|
|
|
$modelClass,
|
|
|
|
|
$search,
|
|
|
|
|
$field
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (is_array($searchValue)) {
|
|
|
|
|
foreach ($searchValue as $enumItem) {
|
|
|
|
|
$query->orWhereLike(
|
|
|
|
|
"{$table}.{$field}",
|
|
|
|
|
"%{$enumItem->value}%"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$query->orWhereLike("{$table}.{$field}", $searchTerm);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $query;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @template TModel of \Illuminate\Database\Eloquent\Model
|
|
|
|
|
*
|
|
|
|
|
* @param class-string<TModel> $modelClass
|
|
|
|
|
* @return string|array<int,\BackedEnum>
|
|
|
|
|
*/
|
|
|
|
|
private function getSearchValue(
|
|
|
|
|
string $modelClass,
|
|
|
|
|
string $search,
|
|
|
|
|
string $field
|
|
|
|
|
) {
|
|
|
|
|
$cast = (new $modelClass())->getCasts()[$field] ?? null;
|
|
|
|
|
|
|
|
|
|
if ($cast && class_exists($cast) && is_subclass_of($cast, BaseEnum::class)) {
|
|
|
|
|
$searchLower = mb_strtolower($search);
|
|
|
|
|
|
|
|
|
|
return array_values(array_filter(
|
|
|
|
|
$cast::cases(),
|
|
|
|
|
fn ($item): bool => str_contains(
|
2026-10-28 08:53:34 +01:00
|
|
|
// @phpstan-ignore method.notFound (PhpStan cannot detect the label() method on BaseEnum cases)
|
2026-06-14 21:32:07 +02:00
|
|
|
mb_strtolower((string) $item->label()),
|
|
|
|
|
$searchLower
|
|
|
|
|
)
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $search;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @return array{sort_col:string,sort_way:\SortDirection} */
|
|
|
|
|
public function getSortRequest(): array
|
|
|
|
|
{
|
|
|
|
|
$rName = $this->request->route()?->getName() ?? 'noroute';
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'sort_col' => $this->request->string(
|
|
|
|
|
self::CACHE_SORT_COL_KEY,
|
|
|
|
|
$this->session->get("{$rName}." . self::CACHE_SORT_COL_KEY, 'undefined')
|
|
|
|
|
)->value(),
|
|
|
|
|
'sort_way' => $this->request->input(
|
|
|
|
|
self::CACHE_SORT_WAY_KEY,
|
|
|
|
|
$this->session->get("{$rName}." . self::CACHE_SORT_WAY_KEY)
|
|
|
|
|
) === 'desc' ? SortDirection::Descending : SortDirection::Ascending,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function persistSortSession(string $rName, ?string $sortCol, ?SortDirection $sortWay): void
|
|
|
|
|
{
|
|
|
|
|
if ($this->request->has(self::CACHE_SORT_COL_KEY)) {
|
|
|
|
|
$this->session->put("{$rName}." . self::CACHE_SORT_COL_KEY, $sortCol);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($this->request->has(self::CACHE_SORT_WAY_KEY)) {
|
|
|
|
|
$direction = match ($sortWay) {
|
|
|
|
|
SortDirection::Descending => 'desc',
|
|
|
|
|
default => 'asc',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
$this->session->put("{$rName}." . self::CACHE_SORT_WAY_KEY, $direction);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function resetSortSession(string $rName): void
|
|
|
|
|
{
|
|
|
|
|
$this->session->forget([
|
|
|
|
|
"{$rName}." . self::CACHE_SORT_COL_KEY,
|
|
|
|
|
"{$rName}." . self::CACHE_SORT_WAY_KEY,
|
|
|
|
|
"{$rName}." . self::CACHE_SORTED_KEY,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Helper to easily paginate and keep query parameters.
|
|
|
|
|
*
|
|
|
|
|
* @template TModel of \Illuminate\Database\Eloquent\Model
|
|
|
|
|
*
|
|
|
|
|
* @param \Illuminate\Database\Eloquent\Builder<TModel> $query
|
|
|
|
|
* @return \Illuminate\Pagination\LengthAwarePaginator<int, TModel>
|
|
|
|
|
*/
|
2026-09-29 16:59:07 +02:00
|
|
|
public function paginateQuery(\Illuminate\Database\Eloquent\Builder $query, Pagination $pagination): LengthAwarePaginator
|
2026-06-14 21:32:07 +02:00
|
|
|
{
|
|
|
|
|
return $query->paginate(
|
feat(employees): automated sync with Arcadia API, BO settings, Shopper value objects, and pagination migration
- Implement automated synchronization of employees via Arcadia API (EmployeeSyncService, SynchronizeEmployeesWithArcadiaApi, FetchAndUpsertArcadiaEmployees)
- Add Back-Office application settings for employee sites (Setting singleton, GetSettingsQuery, UpdateSettings, SettingsController, EmployeeSitesInputComponent.vue)
- Strongly type Employee attributes using Domain Value Objects (ShopperEstablishmentCode, ShopperSiteCode, ShopperServiceCode) and Eloquent Casts
- Migrate pagination from Domain to Shared\Pagination namespace across queries and inputs
- Remove legacy and dead CSV employee import/export code
- Restore Domain\Ports\Services\CsvGenerator interface for Regate order generation
- Add comprehensive Pest unit, integration, and feature test coverage
2026-10-01 17:26:30 +02:00
|
|
|
perPage: $pagination->pageSize()->value(),
|
|
|
|
|
page: $pagination->pageNumber()->value()
|
2026-06-14 21:32:07 +02:00
|
|
|
)->withQueryString();
|
|
|
|
|
}
|
|
|
|
|
}
|