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); } }