This commit is contained in:
2026-03-11 21:48:59 +03:00
parent 980f656a0a
commit 02ad7d83ef
365 changed files with 1 additions and 782 deletions

View File

@@ -0,0 +1,344 @@
<?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);
}
}