Files
interview-demo-code/backend/src/bastion/Handlers/TelegramCustomersHandler.php
2026-03-11 21:48:59 +03:00

345 lines
13 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}