345 lines
13 KiB
PHP
Executable File
345 lines
13 KiB
PHP
Executable File
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Bastion\Handlers;
|
||
|
||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||
use Openguru\OpenCartFramework\Http\Request;
|
||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
|
||
use Openguru\OpenCartFramework\Support\Arr;
|
||
|
||
class TelegramCustomersHandler
|
||
{
|
||
private const TABLE_NAME = 'telecart_customers';
|
||
private const DEFAULT_PAGE = 1;
|
||
private const DEFAULT_ROWS = 20;
|
||
private const DEFAULT_SORT_FIELD = 'last_seen_at';
|
||
private const DEFAULT_SORT_ORDER = 'DESC';
|
||
|
||
private Builder $builder;
|
||
|
||
public function __construct(Builder $builder)
|
||
{
|
||
$this->builder = $builder;
|
||
}
|
||
|
||
/**
|
||
* Получить список Telegram-кастомеров с пагинацией, фильтрацией и сортировкой
|
||
*
|
||
* @param Request $request HTTP запрос с параметрами пагинации, сортировки и фильтров
|
||
* @return JsonResponse JSON ответ с данными и метаинформацией
|
||
*/
|
||
public function getCustomers(Request $request): JsonResponse
|
||
{
|
||
$page = max(1, (int) $request->json('page', self::DEFAULT_PAGE));
|
||
$rows = max(1, (int) $request->json('rows', self::DEFAULT_ROWS));
|
||
$first = ($page - 1) * $rows;
|
||
|
||
$sortField = $request->json('sortField', self::DEFAULT_SORT_FIELD) ?? self::DEFAULT_SORT_FIELD;
|
||
$sortOrder = $this->normalizeSortOrder((string)$request->json('sortOrder', self::DEFAULT_SORT_ORDER));
|
||
|
||
$filters = $request->json('filters', []);
|
||
$globalFilter = Arr::get($filters, 'global.value');
|
||
|
||
// Создаем базовый query с фильтрами
|
||
$query = $this->buildBaseQuery();
|
||
$this->applyFilters($query, $filters, $globalFilter);
|
||
|
||
// Получаем общее количество записей
|
||
$countQuery = $this->buildCountQuery();
|
||
$this->applyFilters($countQuery, $filters, $globalFilter);
|
||
$totalRecords = (int) ($countQuery->value('total') ?? 0);
|
||
|
||
// Применяем сортировку и пагинацию
|
||
$customers = $query
|
||
->orderBy($sortField, $sortOrder)
|
||
->offset($first)
|
||
->limit($rows)
|
||
->get();
|
||
|
||
return new JsonResponse([
|
||
'data' => [
|
||
'data' => $this->mapToResponse($customers),
|
||
'totalRecords' => $totalRecords,
|
||
],
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Создать базовый query для выборки данных
|
||
*
|
||
* @return Builder
|
||
*/
|
||
private function buildBaseQuery(): Builder
|
||
{
|
||
return $this->builder->newQuery()
|
||
->select([
|
||
'id',
|
||
'telegram_user_id',
|
||
'oc_customer_id',
|
||
'tracking_id',
|
||
'username',
|
||
'first_name',
|
||
'last_name',
|
||
'language_code',
|
||
'is_premium',
|
||
'allows_write_to_pm',
|
||
'photo_url',
|
||
'last_seen_at',
|
||
'referral',
|
||
'orders_count',
|
||
'privacy_consented_at',
|
||
'created_at',
|
||
'updated_at',
|
||
])
|
||
->from(self::TABLE_NAME);
|
||
}
|
||
|
||
/**
|
||
* Создать query для подсчета общего количества записей
|
||
*
|
||
* @return Builder
|
||
*/
|
||
private function buildCountQuery(): Builder
|
||
{
|
||
return $this->builder->newQuery()
|
||
->select([new RawExpression('COUNT(*) as total')])
|
||
->from(self::TABLE_NAME);
|
||
}
|
||
|
||
/**
|
||
* Применить фильтры к query
|
||
*
|
||
* @param Builder $query Query builder
|
||
* @param array $filters Массив фильтров
|
||
* @param string|null $globalFilter Глобальный фильтр поиска
|
||
* @return void
|
||
*/
|
||
private function applyFilters(Builder $query, array $filters, ?string $globalFilter): void
|
||
{
|
||
// Применяем глобальный фильтр
|
||
if ($globalFilter) {
|
||
$this->applyGlobalFilter($query, $globalFilter);
|
||
}
|
||
|
||
// Применяем фильтры по колонкам
|
||
$this->applyColumnFilters($query, $filters);
|
||
}
|
||
|
||
/**
|
||
* Применить глобальный фильтр поиска
|
||
*
|
||
* @param Builder $query Query builder
|
||
* @param string $searchTerm Поисковый запрос
|
||
* @return void
|
||
*/
|
||
private function applyGlobalFilter(Builder $query, string $searchTerm): void
|
||
{
|
||
$query->whereNested(function ($q) use ($searchTerm) {
|
||
$q->where('telegram_user_id', 'LIKE', "%{$searchTerm}%")
|
||
->orWhere('username', 'LIKE', "%{$searchTerm}%")
|
||
->orWhere('first_name', 'LIKE', "%{$searchTerm}%")
|
||
->orWhere('last_name', 'LIKE', "%{$searchTerm}%")
|
||
->orWhere('language_code', 'LIKE', "%{$searchTerm}%");
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Применить фильтры по колонкам
|
||
*
|
||
* @param Builder $query Query builder
|
||
* @param array $filters Массив фильтров
|
||
* @return void
|
||
*/
|
||
private function applyColumnFilters(Builder $query, array $filters): void
|
||
{
|
||
foreach ($filters as $field => $filter) {
|
||
if ($field === 'global') {
|
||
continue;
|
||
}
|
||
|
||
// Обработка сложных фильтров (constraints)
|
||
if (isset($filter['constraints']) && is_array($filter['constraints'])) {
|
||
$this->applyConstraintFilters($query, $field, $filter);
|
||
continue;
|
||
}
|
||
|
||
// Обработка простых фильтров (обратная совместимость)
|
||
if (! isset($filter['value']) || $filter['value'] === null || $filter['value'] === '') {
|
||
continue;
|
||
}
|
||
|
||
$value = $filter['value'];
|
||
$matchMode = Arr::get($filter, 'matchMode', 'contains');
|
||
|
||
$this->applyColumnFilter($query, $field, $value, $matchMode);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Применить сложные фильтры с условиями (AND/OR)
|
||
*
|
||
* @param Builder $query Query builder
|
||
* @param string $field Имя поля
|
||
* @param array $filter Данные фильтра
|
||
* @return void
|
||
*/
|
||
private function applyConstraintFilters(Builder $query, string $field, array $filter): void
|
||
{
|
||
$operator = strtolower($filter['operator'] ?? 'and');
|
||
$constraints = $filter['constraints'];
|
||
|
||
// Фильтруем пустые значения (но учитываем false как валидное значение для boolean полей)
|
||
$activeConstraints = array_filter($constraints, function ($constraint) {
|
||
if (!isset($constraint['value'])) {
|
||
return false;
|
||
}
|
||
$value = $constraint['value'];
|
||
// null означает "любой", пропускаем
|
||
if ($value === null) {
|
||
return false;
|
||
}
|
||
// Пустая строка пропускаем
|
||
if ($value === '') {
|
||
return false;
|
||
}
|
||
// false - валидное значение для boolean полей
|
||
return true;
|
||
});
|
||
|
||
if (empty($activeConstraints)) {
|
||
return;
|
||
}
|
||
|
||
$query->whereNested(function ($q) use ($field, $activeConstraints, $operator) {
|
||
// Для первого элемента всегда используем where, чтобы начать группу
|
||
$first = true;
|
||
|
||
foreach ($activeConstraints as $constraint) {
|
||
$value = $constraint['value'];
|
||
$matchMode = $constraint['matchMode'] ?? 'contains';
|
||
|
||
if ($first) {
|
||
$this->applyColumnFilter($q, $field, $value, $matchMode);
|
||
$first = false;
|
||
continue;
|
||
}
|
||
|
||
if ($operator === 'or') {
|
||
$q->orWhere(function ($subQ) use ($field, $value, $matchMode) {
|
||
$this->applyColumnFilter($subQ, $field, $value, $matchMode);
|
||
});
|
||
} else {
|
||
$this->applyColumnFilter($q, $field, $value, $matchMode);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Применить фильтр для одной колонки
|
||
*
|
||
* @param Builder $query Query builder
|
||
* @param string $field Имя поля
|
||
* @param mixed $value Значение фильтра
|
||
* @param string $matchMode Режим совпадения (contains, startsWith, endsWith, equals, notEquals)
|
||
* @return void
|
||
*/
|
||
private function applyColumnFilter(Builder $query, string $field, $value, string $matchMode): void
|
||
{
|
||
if (in_array($matchMode, ['contains', 'startsWith', 'endsWith'], true)) {
|
||
$likeValue = $this->buildLikeValue($value, $matchMode);
|
||
$query->where($field, 'LIKE', $likeValue);
|
||
} elseif ($matchMode === 'equals') {
|
||
$query->where($field, '=', $value);
|
||
} elseif ($matchMode === 'notEquals') {
|
||
$query->where($field, '!=', $value);
|
||
} elseif ($matchMode === 'gt') {
|
||
$query->where($field, '>', $value);
|
||
} elseif ($matchMode === 'lt') {
|
||
$query->where($field, '<', $value);
|
||
} elseif ($matchMode === 'gte') {
|
||
$query->where($field, '>=', $value);
|
||
} elseif ($matchMode === 'lte') {
|
||
$query->where($field, '<=', $value);
|
||
} elseif ($matchMode === 'dateIs') {
|
||
// Для точного совпадения даты используем диапазон от 00:00:00 до 23:59:59
|
||
$date = date('Y-m-d', strtotime($value));
|
||
$query->where($field, '>=', $date . ' 00:00:00')
|
||
->where($field, '<=', $date . ' 23:59:59');
|
||
} elseif ($matchMode === 'dateIsNot') {
|
||
// Для отрицания проверяем, что дата меньше начала дня ИЛИ больше конца дня
|
||
$date = date('Y-m-d', strtotime($value));
|
||
$query->whereNested(function ($q) use ($field, $date) {
|
||
$q->where($field, '<', $date . ' 00:00:00')
|
||
->orWhere($field, '>', $date . ' 23:59:59');
|
||
});
|
||
} elseif ($matchMode === 'dateBefore') {
|
||
$query->where($field, '<', date('Y-m-d 00:00:00', strtotime($value)));
|
||
} elseif ($matchMode === 'dateAfter') {
|
||
// "После" означает после конца указанного дня
|
||
$query->where($field, '>', date('Y-m-d 23:59:59', strtotime($value)));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Построить значение для LIKE запроса
|
||
*
|
||
* @param string $value Значение
|
||
* @param string $matchMode Режим совпадения
|
||
* @return string
|
||
*/
|
||
private function buildLikeValue(string $value, string $matchMode): string
|
||
{
|
||
if ($matchMode === 'startsWith') {
|
||
return "{$value}%";
|
||
}
|
||
|
||
if ($matchMode === 'endsWith') {
|
||
return "%{$value}";
|
||
}
|
||
|
||
return "%{$value}%";
|
||
}
|
||
|
||
/**
|
||
* Нормализовать порядок сортировки
|
||
*
|
||
* @param string $sortOrder Порядок сортировки
|
||
* @return string 'ASC' или 'DESC'
|
||
*/
|
||
private function normalizeSortOrder(string $sortOrder): string
|
||
{
|
||
$normalized = strtoupper($sortOrder);
|
||
|
||
return in_array($normalized, ['ASC', 'DESC'], true) ? $normalized : self::DEFAULT_SORT_ORDER;
|
||
}
|
||
|
||
private function mapToResponse(array $customers): array
|
||
{
|
||
return array_map(static function (array $customer) {
|
||
return [
|
||
'id' => (int) $customer['id'],
|
||
'telegram_user_id' => (int) $customer['telegram_user_id'],
|
||
'oc_customer_id' => (int) $customer['oc_customer_id'],
|
||
'tracking_id' => $customer['tracking_id'],
|
||
'username' => $customer['username'],
|
||
'first_name' => $customer['first_name'],
|
||
'last_name' => $customer['last_name'],
|
||
'language_code' => $customer['language_code'],
|
||
'is_premium' => filter_var($customer['is_premium'], FILTER_VALIDATE_BOOLEAN),
|
||
'allows_write_to_pm' => filter_var($customer['allows_write_to_pm'], FILTER_VALIDATE_BOOLEAN),
|
||
'photo_url' => $customer['photo_url'],
|
||
'last_seen_at' => $customer['last_seen_at'],
|
||
'referral' => $customer['referral'],
|
||
'orders_count' => (int) $customer['orders_count'],
|
||
'privacy_consented_at' => $customer['privacy_consented_at'],
|
||
'created_at' => $customer['created_at'],
|
||
'updated_at' => $customer['updated_at'],
|
||
];
|
||
}, $customers);
|
||
}
|
||
}
|