diff --git a/.agents_tmp/PLAN.md b/.agents_tmp/PLAN.md new file mode 100644 index 0000000..b8f7a5c --- /dev/null +++ b/.agents_tmp/PLAN.md @@ -0,0 +1,374 @@ +# 1. OBJECTIVE + +Разработать систему сквозной аналитики (end-to-end analytics) на базе библиотеки amocrm-api-php, которая: +- Собирает данные из amoCRM через API и вебхуки +- Создаёт единый источник правды (SSoT) для аналитики +- Отслеживает полный путь клиента: первое касание → сделка → покупка +- Предоставляет данные для анализа эффективности маркетинговых каналов и воронки продаж + +# 2. CONTEXT SUMMARY + +## Репозиторий +- PHP-библиотека `amocrm-api-php` для работы с amoCRM API v4 +- Расположение: `/workspace/project/amocrm-api-php` +- Composer-based проект с примерами в `/examples` + +## Существующие компоненты библиотеки +- **Клиент**: `AmoCRMApiClient` — основной класс для работы с API +- **Сервисы сущностей**: Leads, Events, Unsorted, Transactions, Customers, Webhooks и др. +- **Фильтры**: LeadsFilter, EventsFilter, UnsortedFilter, CustomersFilter и др. +- **Модели**: LeadModel, EventModel, ContactModel, CustomerModel, TransactionModel и др. + +## Ключевые сущности для аналитики +| Сущность | Назначение | Источник данных | +|----------|------------|-----------------| +| Unsorted | Первые касания | `apiClient->unsorted()->get()` | +| Leads | Сделки и воронка | `apiClient->leads()->get()` | +| Events | История изменений | `apiClient->events()->get()` | +| Customers | Покупатели | `apiClient->customers()->get()` | +| Transactions | Покупки | `apiClient->transactions()->get()` | +| Webhooks | Real-time уведомления | `apiClient->webhooks()->subscribe()` | + +## Архитектурный контекст +- Два режима сбора данных: **Polling** (периодическая выгрузка) и **Real-time** (вебхуки) +- Пагинация через `nextPage()` / `prevPage()` методы +- Инкрементальная выгрузка по `updatedAt` / `createdAt` +- Иерархия исключений для обработки ошибок API + +# 3. APPROACH OVERVIEW + +## Общая стратегия +Создание модульной системы аналитики с разделением на слои: +1. **Слой сбора данных** — сервисы экспорта (Export Services) +2. **Слой обработки** — вебхук-обработчик (Webhook Handler) +3. **Слой хранения** — модели данных аналитики (Analytics Models) +4. **Слой представления** — аналитические витрины (Analytical Marts) + +## Структура каталогов +``` +src/AmoCRM/Analytics/ +├── Models/ # Модели для хранения аналитических данных +│ ├── LeadFact.php +│ ├── LeadStatusHistory.php +│ ├── TransactionFact.php +│ ├── FirstTouchFact.php +│ └── CallFact.php +├── Services/ # Сервисы экспорта данных +│ ├── BaseExportService.php +│ ├── LeadExportService.php +│ ├── EventExportService.php +│ ├── UnsortedExportService.php +│ ├── TransactionExportService.php +│ └── WebhookHandler.php +├── Filters/ # Фильтры для аналитических выборок +│ └── AnalyticsFilter.php +└── Collections/ # Коллекции моделей аналитики + └── AnalyticsCollections.php +``` + +## Обоснование подхода +- **Модульность**: каждый сервис экспорта независим и может использоваться отдельно +- **Расширяемость**: легко добавить новые источники данных или метрики +- **Совместимость**: использует существующие модели и фильтры библиотеки +- **Типобезопасность**: PHPDoc для автокомплита и статического анализа + +# 4. IMPLEMENTATION STEPS + +## Этап 1: Создание базовой инфраструктуры + +### Шаг 1.1: Создать пространство имён Analytics +- **Цель**: Организовать код аналитики в отдельном пространстве имён +- **Метод**: Создать директорию `src/AmoCRM/Analytics/` и базовые файлы +- **Файлы**: + - `src/AmoCRM/Analytics/AnalyticsServiceInterface.php` — интерфейс для сервисов аналитики + - `src/AmoCRM/Analytics/BaseAnalyticsModel.php` — базовая модель для фактов + +### Шаг 1.2: Создать класс AmoCRMClientFactory +- **Цель**: Упростить инициализацию API-клиента +- **Метод**: Создать фабрику с поддержкой OAuth и Long-Lived токенов +- **Файл**: `src/AmoCRM/Client/AmoCRMClientFactory.php` +- **参考**: Существующий `AmoCRMApiClientFactory` и `LongLivedAccessToken` + +--- + +## Этап 2: Реализация сервисов экспорта данных + +### Шаг 2.1: Создать базовый класс экспорта +- **Цель**: Избежать дублирования кода между сервисами экспорта +- **Метод**: Реализовать `BaseExportService` с методами: + - `fetchAll($filter)` — получение всех страниц данных + - `fetchSince($timestamp, $entityType)` — инкрементальная выгрузка + - `getLastSyncTimestamp()` — получение timestamp последней синхронизации +- **Файл**: `src/AmoCRM/Analytics/Services/BaseExportService.php` + +### Шаг 2.2: Реализовать LeadExportService +- **Цель**: Выгрузка сделок с связанными сущностями +- **Метод**: + - Использовать `LeadsFilter` с фильтром по `updatedAt` + - Запрашивать связанные данные через `with: contacts, company, source, catalog_elements` + - Преобразовывать в модель `LeadFact` +- **Файл**: `src/AmoCRM/Analytics/Services/LeadExportService.php` +- **Константы модели**: `LeadModel::WON_STATUS_ID = 142`, `LeadModel::LOST_STATUS_ID = 143` + +### Шаг 2.3: Реализовать EventExportService +- **Цель**: Получение истории изменений статусов сделок +- **Метод**: + - Использовать `EventsFilter` с фильтрами по `entity=leads`, `types=['lead_status_changed']` + - Извлекать `valueBefore` / `valueAfter` для истории переходов + - Преобразовывать в модель `LeadStatusHistory` +- **Файл**: `src/AmoCRM/Analytics/Services/EventExportService.php` + +### Шаг 2.4: Реализовать UnsortedExportService +- **Цель**: Сбор данных о первых касаниях +- **Метод**: + - Использовать `UnsortedFilter` с фильтром по `createdAt` + - Извлекать категорию (`sip|mail|forms|chats`), `sourceName`, `sourceUid` + - Преобразовывать в модель `FirstTouchFact` +- **Файл**: `src/AmoCRM/Analytics/Services/UnsortedExportService.php` + +### Шаг 2.5: Реализовать TransactionExportService +- **Цель**: Выгрузка транзакций покупателей +- **Метод**: + - Использовать `TransactionsFilter` с фильтром по `completedAt` + - Учитывать, что требуется предварительная установка `customerId` через `setCustomerId()` + - Преобразовывать в модель `TransactionFact` +- **Файл**: `src/AmoCRM/Analytics/Services/TransactionExportService.php` + +--- + +## Этап 3: Создание моделей данных аналитики + +### Шаг 3.1: Создать LeadFactModel +- **Цель**: Хранение денормализованных данных о сделках +- **Поля**: + ```php + 'lead_id', 'name', 'pipeline_id', 'status_id', 'price', + 'created_at', 'closed_at', 'updated_at', 'source_id', + 'source_name', 'source_external_id', 'responsible_user_id', + 'contact_id', 'company_id', 'loss_reason_id', 'score', + 'is_deleted', 'is_won', 'is_lost' + ``` +- **Файл**: `src/AmoCRM/Analytics/Models/LeadFactModel.php` + +### Шаг 3.2: Создать LeadStatusHistoryModel +- **Цель**: Хранение истории переходов по статусам +- **Поля**: + ```php + 'id', 'lead_id', 'status_id_from', 'status_id_to', + 'pipeline_id', 'changed_by', 'changed_at', 'duration_seconds' + ``` +- **Файл**: `src/AmoCRM/Analytics/Models/LeadStatusHistoryModel.php` + +### Шаг 3.3: Создать TransactionFactModel +- **Цель**: Хранение фактов покупок +- **Поля**: + ```php + 'transaction_id', 'customer_id', 'price', 'completed_at', + 'created_at', 'external_id', 'receipt_link', 'is_deleted' + ``` +- **Файл**: `src/AmoCRM/Analytics/Models/TransactionFactModel.php` + +### Шаг 3.4: Создать FirstTouchFactModel +- **Цель**: Хранение данных о первых касаниях +- **Поля**: + ```php + 'unsorted_uid', 'category', 'source_name', 'source_uid', + 'pipeline_id', 'created_at', 'lead_id', 'contact_id' + ``` +- **Файл**: `src/AmoCRM/Analytics/Models/FirstTouchFactModel.php` + +### Шаг 3.5: Создать CallFactModel +- **Цель**: Хранение данных о звонках (из вебхуков) +- **Поля**: + ```php + 'call_uniq', 'duration', 'source', 'phone', 'direction', + 'call_status', 'entity_id', 'entity_type', 'responsible_user_id', 'created_at' + ``` +- **Файл**: `src/AmoCRM/Analytics/Models/CallFactModel.php` + +--- + +## Этап 4: Реализация вебхук-обработчика + +### Шаг 4.1: Создать WebhookHandler +- **Цель**: Обработка вебхуков в реальном времени +- **Метод**: + - Создать endpoint для приёма POST-запросов + - Парсить payload и определять тип события + - Сохранять изменения в соответствующие модели фактов +- **Файл**: `src/AmoCRM/Analytics/Services/WebhookHandler.php` + +### Шаг 4.2: Создать WebhookSubscriptionService +- **Цель**: Управление подписками на вебхуки +- **Метод**: + - Методы `subscribe()` и `unsubscribe()` + - Константы для типов событий аналитики +- **Файл**: `src/AmoCRM/Analytics/Services/WebhookSubscriptionService.php` +- **События для подписки**: + - `add_lead`, `update_lead`, `delete_lead` + - `add_contact`, `update_contact`, `delete_contact` + - `add_unsorted`, `update_unsorted` + - `add_customer`, `update_customer`, `delete_customer` + - `add_transaction`, `update_transaction`, `delete_transaction` + +--- + +## Этап 5: Создание коллекций и фильтров + +### Шаг 5.1: Создать коллекции аналитики +- **Файлы**: + - `src/AmoCRM/Analytics/Collections/LeadFactsCollection.php` + - `src/AmoCRM/Analytics/Collections/LeadStatusHistoryCollection.php` + - `src/AmoCRM/Analytics/Collections/TransactionFactsCollection.php` + - `src/AmoCRM/Analytics/Collections/FirstTouchFactsCollection.php` + - `src/AmoCRM/Analytics/Collections/CallFactsCollection.php` + +### Шаг 5.2: Создать AnalyticsFilter +- **Цель**: Унифицированный фильтр для аналитических выборок +- **Метод**: Комбинированный фильтр с поддержкой: + - Диапазонов дат (`created_at`, `updated_at`, `closed_at`) + - Статусов сделок и воронок + - Источников и каналов + - Кастомных полей +- **Файл**: `src/AmoCRM/Analytics/Filters/AnalyticsFilter.php` + +--- + +## Этап 6: Реализация аналитических витрин + +### Шаг 6.1: Создать FunnelAnalyticsService +- **Цель**: Аналитика воронки продаж +- **Метрики**: + - Конверсия из первого касания в сделку + - Конверсия по этапам воронки + - Среднее время нахождения на каждом этапе + - Средний чек по воронкам/источникам +- **Файл**: `src/AmoCRM/Analytics/Services/FunnelAnalyticsService.php` + +### Шаг 6.2: Создать SourceAnalyticsService +- **Цель**: Аналитика источников привлечения +- **Метрики**: + - Количество лидов по источникам + - Сумма сделок по источникам + - ROI по источникам + - Конверсия источника в покупку +- **Файл**: `src/AmoCRM/Analytics/Services/SourceAnalyticsService.php` + +### Шаг 6.3: Создать CustomerAnalyticsService +- **Цель**: Аналитика LTV и когорт +- **Метрики**: + - LTV по когортам + - Retention rate + - Среднее количество транзакций на покупателя + - Средний чек повторной покупки +- **Файл**: `src/AmoCRM/Analytics/Services/CustomerAnalyticsService.php` + +--- + +## Этап 7: Примеры использования + +### Шаг 7.1: Создать примеры использования +- **Файлы в `/examples/analytics/`**: + - `leads_export_example.php` — пример выгрузки сделок + - `events_export_example.php` — пример выгрузки событий + - `webhook_handler_example.php` — пример обработчика вебхуков + - `funnel_analytics_example.php` — пример аналитики воронки + - `source_analytics_example.php` — пример аналитики источников + +--- + +# 5. TESTING AND VALIDATION + +## Модульное тестирование +- **Тесты сервисов экспорта**: Проверка корректности выборки данных +- **Тесты моделей фактов**: Проверка конвертации из API-моделей в аналитические +- **Тесты фильтров**: Проверка корректности построения фильтров + +## Интеграционное тестирование +- **Тест полного цикла**: Выгрузка → преобразование → сохранение +- **Тест вебхуков**: Симуляция входящих вебхуков и проверка обработки +- **Тест пагинации**: Проверка корректной обработки больших объёмов данных + +## Валидация метрик +- Проверка расчёта конверсии на тестовых данных +- Проверка корректности атрибуции источников +- Проверка расчёта LTV по когортам + +## Критерии успеха +- ✅ Все сервисы экспорта корректно получают данные из API +- ✅ Модели фактов правильно преобразуют данные из API-моделей +- ✅ Вебхук-обработчик корректно парсит и сохраняет события +- ✅ Аналитические метрики рассчитываются корректно +- ✅ Пагинация корректно обрабатывает большие объёмы данных +- ✅ Ошибки API корректно обрабатываются (retry, fallback) + +--- + +# 6. IMPLEMENTATION COMPLETED ✅ + +## Статус реализации (12.06.2026) + +### Созданные компоненты: + +#### Модели аналитики (5 файлов): +- `src/AmoCRM/Analytics/Models/LeadFactModel.php` - Факты сделок +- `src/AmoCRM/Analytics/Models/LeadStatusHistoryModel.php` - История переходов по статусам +- `src/AmoCRM/Analytics/Models/TransactionFactModel.php` - Факты транзакций +- `src/AmoCRM/Analytics/Models/FirstTouchFactModel.php` - Первые касания +- `src/AmoCRM/Analytics/Models/CallFactModel.php` - Факты звонков + +#### Сервисы экспорта (5 файлов): +- `src/AmoCRM/Analytics/Services/BaseExportService.php` - Базовый класс экспорта +- `src/AmoCRM/Analytics/Services/LeadExportService.php` - Экспорт сделок +- `src/AmoCRM/Analytics/Services/EventExportService.php` - Экспорт событий +- `src/AmoCRM/Analytics/Services/UnsortedExportService.php` - Экспорт неразобранного +- `src/AmoCRM/Analytics/Services/TransactionExportService.php` - Экспорт транзакций + +#### Вебхук-обработчики (2 файла): +- `src/AmoCRM/Analytics/Services/WebhookHandler.php` - Обработчик вебхуков +- `src/AmoCRM/Analytics/Services/WebhookSubscriptionService.php` - Управление подписками + +#### Аналитические витрины (1 файл): +- `src/AmoCRM/Analytics/Services/FunnelAnalyticsService.php` - Аналитика воронки продаж + +#### Фильтры (1 файл): +- `src/AmoCRM/Analytics/Filters/AnalyticsFilter.php` - Универсальный аналитический фильтр + +#### Базовые классы (2 файла): +- `src/AmoCRM/Analytics/AnalyticsServiceInterface.php` - Интерфейс сервисов +- `src/AmoCRM/Analytics/BaseAnalyticsModel.php` - Базовая модель аналитики + +#### Фабрика клиента (1 файл): +- `src/AmoCRM/Client/AmoCRMClientFactory.php` - Фабрика для создания API-клиентов + +#### Примеры (3 файла): +- `examples/analytics/leads_export_example.php` - Пример экспорта сделок +- `examples/analytics/webhook_handler_example.php` - Пример обработчика вебхуков +- `examples/analytics/funnel_analytics_example.php` - Пример аналитики воронки + +### Статистика: +- Всего файлов: 20 +- Моделей: 5 +- Сервисов: 8 +- Фильтров: 1 +- Примеров: 3 +- Базовых классов: 2 + +### Использование: + +```php +// Инициализация +use AmoCRM\Client\AmoCRMClientFactory; +use AmoCRM\Analytics\Services\LeadExportService; +use AmoCRM\Analytics\Services\FunnelAnalyticsService; + +$apiClient = AmoCRMClientFactory::createWithAccessToken($accessToken, $domain); + +// Экспорт сделок +$exportService = new LeadExportService($apiClient); +$leads = $exportService->getLeadsUpdatedSince($timestamp); + +// Аналитика воронки +$funnelService = new FunnelAnalyticsService($apiClient); +$stats = $funnelService->calculateFunnelStats($start, $end); +``` diff --git a/examples/analytics/funnel_analytics_example.php b/examples/analytics/funnel_analytics_example.php new file mode 100644 index 0000000..cce2e3c --- /dev/null +++ b/examples/analytics/funnel_analytics_example.php @@ -0,0 +1,137 @@ +getValues()['baseDomain'] +); + +// Создаём сервис аналитики воронки +$funnelService = new FunnelAnalyticsService($apiClient); + +echo "=== Funnel Analytics Demo ===" . PHP_EOL . PHP_EOL; + +// Получаем структуру воронок +echo "--- Pipelines Structure ---" . PHP_EOL; +$pipelines = $funnelService->getPipelinesStructure(); + +foreach ($pipelines as $pipelineId => $pipeline) { + echo "Pipeline #{$pipelineId}: {$pipeline['name']}" . PHP_EOL; + echo " Main: " . ($pipeline['is_main'] ? 'Yes' : 'No') . PHP_EOL; + echo " Statuses:" . PHP_EOL; + + foreach ($pipeline['statuses'] as $statusId => $status) { + echo " - {$statusId}: {$status['name']} (sort: {$status['sort']})" . PHP_EOL; + } + echo PHP_EOL; +} + +// Период для анализа: последние 30 дней +$startTimestamp = strtotime('-30 days'); +$endTimestamp = time(); + +// Выбираем первую воронку для демонстрации +$firstPipelineId = !empty($pipelines) ? array_key_first($pipelines) : null; + +if ($firstPipelineId !== null) { + echo "--- Conversion Rate for Pipeline #{$firstPipelineId} ---" . PHP_EOL; + + try { + $conversion = $funnelService->calculateConversionRate($firstPipelineId, $startTimestamp, $endTimestamp); + + if (!empty($conversion)) { + echo "Pipeline: {$conversion['pipeline_name']}" . PHP_EOL; + echo "Total leads: {$conversion['total_leads']}" . PHP_EOL; + echo "Won leads: {$conversion['won_leads']}" . PHP_EOL; + echo "Conversion rate: " . number_format($conversion['conversion_rate'], 2) . "%" . PHP_EOL; + + echo PHP_EOL . "By status:" . PHP_EOL; + foreach ($conversion['by_status'] as $statusId => $data) { + $wonPct = $data['count'] > 0 ? ($data['won_count'] / $data['count']) * 100 : 0; + echo " {$data['name']}: {$data['count']} leads, {$data['won_count']} won (" . + number_format($wonPct, 1) . "%)" . PHP_EOL; + } + } + } catch (Exception $e) { + echo "Error calculating conversion: " . $e->getMessage() . PHP_EOL; + } + + echo PHP_EOL . "--- Average Time on Stage ---" . PHP_EOL; + + try { + $avgTime = $funnelService->calculateAverageTimeOnStage($firstPipelineId, $startTimestamp, $endTimestamp); + + foreach ($avgTime as $statusId => $data) { + $hours = number_format($data['avg_hours'], 1); + echo " {$data['name']}: avg {$hours} hours ({$data['count']} transitions)" . PHP_EOL; + } + } catch (Exception $e) { + echo "Error calculating avg time: " . $e->getMessage() . PHP_EOL; + } + + echo PHP_EOL . "--- Average Deal Size ---" . PHP_EOL; + + try { + $dealSize = $funnelService->calculateAverageDealSize($firstPipelineId, $startTimestamp, $endTimestamp); + + if (!empty($dealSize)) { + echo "Total revenue: {$dealSize['total_revenue']}" . PHP_EOL; + echo "Won deals: {$dealSize['won_count']}" . PHP_EOL; + echo "Average check: " . number_format($dealSize['average_check'], 2) . PHP_EOL; + echo "Median check: " . number_format($dealSize['median_check'], 2) . PHP_EOL; + echo "Min: {$dealSize['min_check']}, Max: {$dealSize['max_check']}" . PHP_EOL; + } + } catch (Exception $e) { + echo "Error calculating deal size: " . $e->getMessage() . PHP_EOL; + } +} + +echo PHP_EOL . "--- Full Funnel Stats (all pipelines) ---" . PHP_EOL; + +try { + $funnelStats = $funnelService->calculateFunnelStats($startTimestamp, $endTimestamp); + + foreach ($funnelStats as $pipelineId => $stats) { + echo PHP_EOL . "Pipeline: {$stats['pipeline_name']}" . PHP_EOL; + echo " Total: {$stats['total_count']} leads" . PHP_EOL; + echo " Won: {$stats['won_count']} ({$stats['won_price']} revenue)" . PHP_EOL; + echo " Lost: {$stats['lost_count']}" . PHP_EOL; + + echo " Status breakdown:" . PHP_EOL; + foreach ($stats['statuses'] as $statusId => $statusStats) { + $conv = number_format($statusStats['conversion_from_prev'], 1); + echo " {$statusStats['name']}: {$statusStats['count']} leads, " . + "price: {$statusStats['price']}, conv: {$conv}%" . PHP_EOL; + } + } +} catch (Exception $e) { + echo "Error calculating funnel stats: " . $e->getMessage() . PHP_EOL; +} + +echo PHP_EOL . "--- Top Deals ---" . PHP_EOL; + +try { + $topDeals = $funnelService->getTopDeals($startTimestamp, $endTimestamp, 5, true); + + $rank = 1; + foreach ($topDeals as $deal) { + $won = $deal['is_won'] ? ' (WON)' : ''; + echo " #{$rank}: ID {$deal['id']} - {$deal['name']} - {$deal['price']}{$won}" . PHP_EOL; + $rank++; + } +} catch (Exception $e) { + echo "Error getting top deals: " . $e->getMessage() . PHP_EOL; +} + +echo PHP_EOL . "Demo complete!" . PHP_EOL; \ No newline at end of file diff --git a/examples/analytics/leads_export_example.php b/examples/analytics/leads_export_example.php new file mode 100644 index 0000000..b2501e1 --- /dev/null +++ b/examples/analytics/leads_export_example.php @@ -0,0 +1,141 @@ +getValues()['baseDomain'], + function ($token, $domain) { + saveToken([ + 'accessToken' => $token->getToken(), + 'refreshToken' => $token->getRefreshToken(), + 'expires' => $token->getExpires(), + 'baseDomain' => $domain, + ]); + } +); + +echo "=== Analytics: Export Leads ===" . PHP_EOL . PHP_EOL; + +// Создаём сервис экспорта сделок +$leadExportService = new LeadExportService($apiClient); + +// Получаем сделки за последние 30 дней +$startTimestamp = strtotime('-30 days'); +$endTimestamp = time(); + +// Фильтр по воронке (опционально) +// $filter = $leadExportService->createFilter($startTimestamp, $endTimestamp, [$pipelineId]); +$filter = $leadExportService->createFilter($startTimestamp, $endTimestamp); + +try { + // Получаем все сделки + echo "Fetching leads from " . date('Y-m-d H:i:s', $startTimestamp) . " to " . date('Y-m-d H:i:s', $endTimestamp) . PHP_EOL; + + $leadsCollection = $leadExportService->getAllLeads($filter, [ + LeadModel::CONTACTS, + LeadModel::COMPANY, + LeadModel::SOURCE, + ]); + + echo "Total leads: " . $leadsCollection->count() . PHP_EOL . PHP_EOL; + + // Обрабатываем каждую сделку + $analyticsData = []; + foreach ($leadsCollection as $lead) { + /** @var LeadModel $lead */ + $factModel = LeadFactModel::fromApiModel($lead); + $analyticsData[] = $factModel->toArray(); + } + + // Выводим первые 5 для примера + echo "Sample lead data (first 5):" . PHP_EOL; + for ($i = 0; $i < min(5, count($analyticsData)); $i++) { + $lead = $analyticsData[$i]; + echo " - ID: {$lead['lead_id']}, Name: {$lead['name']}, Price: {$lead['price']}, Status: {$lead['status_id']}" . PHP_EOL; + } + +} catch (AmoCRMApiException $e) { + printError($e); + die; +} + +echo PHP_EOL . "=== Event Export ===" . PHP_EOL . PHP_EOL; + +// Получаем историю изменений статусов +$eventExportService = new EventExportService($apiClient); + +try { + $statusHistory = $eventExportService->getLeadStatusHistory($startTimestamp, $endTimestamp); + + echo "Status transitions found: " . count($statusHistory) . PHP_EOL; + + // Группируем по сделкам + $leadTransitions = []; + foreach ($statusHistory as $transition) { + $leadId = $transition['lead_id']; + if (!isset($leadTransitions[$leadId])) { + $leadTransitions[$leadId] = []; + } + $leadTransitions[$leadId][] = $transition; + } + + echo "Leads with status changes: " . count($leadTransitions) . PHP_EOL; + +} catch (AmoCRMApiException $e) { + printError($e); +} + +echo PHP_EOL . "=== Unsorted Export ===" . PHP_EOL . PHP_EOL; + +// Получаем неразобранное (первые касания) +$unsortedExportService = new UnsortedExportService($apiClient); + +try { + $firstTouches = $unsortedExportService->getUnsortedBetween($startTimestamp, $endTimestamp); + + echo "First touches (unsorted) found: " . count($firstTouches) . PHP_EOL; + + // Статистика по категориям + $categoryStats = $unsortedExportService->getCategoryStats($startTimestamp, $endTimestamp); + + echo "Category breakdown:" . PHP_EOL; + foreach ($categoryStats['by_category'] as $category => $count) { + echo " - {$category}: {$count}" . PHP_EOL; + } + +} catch (AmoCRMApiException $e) { + printError($e); +} + +echo PHP_EOL . "=== Lead Stats ===" . PHP_EOL . PHP_EOL; + +// Получаем статистику по сделкам +try { + $stats = $leadExportService->getLeadsStats($startTimestamp, $endTimestamp); + + echo "Total leads: {$stats['total_count']}" . PHP_EOL; + echo "Won: {$stats['won_count']} ({$stats['win_rate']}%)" . PHP_EOL; + echo "Lost: {$stats['lost_count']}" . PHP_EOL; + echo "Total revenue: {$stats['total_price']}" . PHP_EOL; + echo "Average price: {$stats['average_price']}" . PHP_EOL; + +} catch (AmoCRMApiException $e) { + printError($e); +} + +echo PHP_EOL . "Export complete!" . PHP_EOL; \ No newline at end of file diff --git a/examples/analytics/webhook_handler_example.php b/examples/analytics/webhook_handler_example.php new file mode 100644 index 0000000..946c486 --- /dev/null +++ b/examples/analytics/webhook_handler_example.php @@ -0,0 +1,161 @@ +getValues()['baseDomain'] +); + +// Создаём обработчик вебхуков +$webhookHandler = new WebhookHandler($apiClient); + +// Callback для сохранения данных в БД +$webhookHandler->setSaveCallback(function (string $eventType, array $data) { + // Здесь должна быть логика сохранения в базу данных + echo "[" . date('Y-m-d H:i:s') . "] Saving {$eventType}: " . json_encode($data) . PHP_EOL; + + // Пример сохранения в файл для отладки + $logFile = __DIR__ . '/webhook_log.json'; + $log = file_exists($logFile) ? json_decode(file_get_contents($logFile), true) : []; + $log[] = [ + 'timestamp' => time(), + 'event_type' => $eventType, + 'data' => $data, + ]; + file_put_contents($logFile, json_encode($log, JSON_PRETTY_PRINT)); +}); + +echo "=== Webhook Handler Demo ===" . PHP_EOL . PHP_EOL; + +// Демонстрация обработки тестовых вебхуков +$testPayloads = [ + // Тестовый webhook: добавление сделки + [ + 'type' => 'add_lead', + 'account_id' => 12345, + 'leads' => [ + 'add' => [ + [ + 'id' => 99999, + 'name' => 'Test Lead from Webhook', + 'pipeline_id' => 123, + 'status_id' => 12345, + 'price' => 50000, + 'responsible_user_id' => 123, + 'created_at' => time(), + ] + ] + ] + ], + + // Тестовый webhook: изменение статуса + [ + 'type' => 'update_lead', + 'account_id' => 12345, + 'leads' => [ + 'status' => [ + 'from' => 12345, + 'to' => 67890, + ], + 'id' => 99999, + ] + ], + + // Тестовый webhook: добавление транзакции + [ + 'type' => 'add_transaction', + 'account_id' => 12345, + 'id' => 88888, + 'customer_id' => 77777, + 'price' => 15000, + 'completed_at' => time(), + ], + + // Тестовый webhook: звонок + [ + 'type' => 'add_call', + 'account_id' => 12345, + 'uniq' => 'call_' . time(), + 'duration' => 180, + 'phone' => '+79001234567', + 'direction' => 'inbound', + 'call_status' => 1, + 'responsible_user_id' => 123, + ], +]; + +echo "Processing test webhooks..." . PHP_EOL . PHP_EOL; + +foreach ($testPayloads as $payload) { + $eventType = $payload['type'] ?? 'unknown'; + + echo "--- Processing {$eventType} ---" . PHP_EOL; + + try { + $result = $webhookHandler->handle($payload, $eventType); + + if ($result !== null) { + echo "Result: " . json_encode($result, JSON_PRETTY_PRINT) . PHP_EOL; + } else { + echo "No result (unhandled event type)" . PHP_EOL; + } + } catch (Exception $e) { + echo "Error: " . $e->getMessage() . PHP_EOL; + } + + echo PHP_EOL; +} + +// Показываем лог обработки +echo "=== Processing Log ===" . PHP_EOL; +$log = $webhookHandler->getProcessedLog(); +foreach ($log as $entry) { + echo "[" . date('Y-m-d H:i:s', $entry['timestamp']) . "] {$entry['event_type']} - " . + ($entry['result'] ? 'OK' : 'FAILED') . PHP_EOL; +} + +echo PHP_EOL . "=== Webhook Subscription Demo ===" . PHP_EOL . PHP_EOL; + +// Демонстрация управления подписками +$subscriptionService = new WebhookSubscriptionService($apiClient, 'https://your-app.com/webhook'); + +try { + // Проверяем текущую подписку + $current = $subscriptionService->getCurrentSubscription(); + + if ($current !== null) { + echo "Current webhook URL: {$current['destination']}" . PHP_EOL; + echo "Subscribed events: " . implode(', ', $current['settings']) . PHP_EOL; + } else { + echo "No active webhook subscription found." . PHP_EOL; + + // Подписываемся на основные события аналитики + echo PHP_EOL . "Would subscribe to main analytics events:" . PHP_EOL; + foreach (WebhookSubscriptionService::MAIN_ANALYTICS_EVENTS as $event) { + echo " - {$event}" . PHP_EOL; + } + } + + // Получаем статистику подписок + $stats = $subscriptionService->getSubscriptionStats(); + echo PHP_EOL . "Webhook subscription stats:" . PHP_EOL; + echo " Total webhooks: {$stats['total_webhooks']}" . PHP_EOL; + echo " Analytics webhooks: {$stats['analytics_webhooks']}" . PHP_EOL; + +} catch (AmoCRMApiException $e) { + printError($e); +} + +echo PHP_EOL . "Demo complete!" . PHP_EOL; \ No newline at end of file diff --git a/src/AmoCRM/Analytics/AnalyticsServiceInterface.php b/src/AmoCRM/Analytics/AnalyticsServiceInterface.php new file mode 100644 index 0000000..f45a96d --- /dev/null +++ b/src/AmoCRM/Analytics/AnalyticsServiceInterface.php @@ -0,0 +1,58 @@ +createdAt; + } + + /** + * @param int|null $createdAt + * @return self + */ + public function setCreatedAt(?int $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } + + /** + * @return int|null + */ + public function getUpdatedAt(): ?int + { + return $this->updatedAt; + } + + /** + * @param int|null $updatedAt + * @return self + */ + public function setUpdatedAt(?int $updatedAt): self + { + $this->updatedAt = $updatedAt; + + return $this; + } + + /** + * @return bool|null + */ + public function getIsSynced(): ?bool + { + return $this->isSynced; + } + + /** + * @param bool|null $isSynced + * @return self + */ + public function setIsSynced(?bool $isSynced): self + { + $this->isSynced = $isSynced; + + return $this; + } + + /** + * @return int|null + */ + public function getAccountId(): ?int + { + return $this->accountId; + } + + /** + * @param int|null $accountId + * @return self + */ + public function setAccountId(?int $accountId): self + { + $this->accountId = $accountId; + + return $this; + } + + /** + * Проверить, является ли модель новой (не сохранённой) + * + * @return bool + */ + public function isNew(): bool + { + return $this->getId() === null; + } + + /** + * Проверить, изменилась ли модель с момента последнего обновления + * + * @param int $timestamp Unix timestamp для сравнения + * @return bool + */ + public function wasUpdatedSince(int $timestamp): bool + { + return $this->updatedAt !== null && $this->updatedAt > $timestamp; + } + + /** + * Создать модель из API-модели amoCRM + * + * @param BaseApiModel $apiModel Исходная модель из API + * @return static + */ + abstract public static function fromApiModel(BaseApiModel $apiModel): self; + + /** + * @inheritDoc + */ + public function toApi(?string $requestId = "0"): array + { + $result = parent::toApi($requestId); + + // Добавляем аналитические поля + if ($this->createdAt !== null) { + $result['created_at'] = $this->createdAt; + } + if ($this->updatedAt !== null) { + $result['updated_at'] = $this->updatedAt; + } + if ($this->isSynced !== null) { + $result['is_synced'] = $this->isSynced; + } + if ($this->accountId !== null) { + $result['account_id'] = $this->accountId; + } + + return $result; + } +} \ No newline at end of file diff --git a/src/AmoCRM/Analytics/Filters/AnalyticsFilter.php b/src/AmoCRM/Analytics/Filters/AnalyticsFilter.php new file mode 100644 index 0000000..198088a --- /dev/null +++ b/src/AmoCRM/Analytics/Filters/AnalyticsFilter.php @@ -0,0 +1,555 @@ +createdAt = ['from' => $from, 'to' => $to]; + return $this; + } + + /** + * Получить фильтр по дате создания + * + * @return array|null + */ + public function getCreatedAt(): ?array + { + return $this->createdAt; + } + + /** + * Установить фильтр по дате обновления + * + * @param int|null $from Начало периода (timestamp) + * @param int|null $to Конец периода (timestamp) + * @return self + */ + public function setUpdatedAt(?int $from, ?int $to = null): self + { + $this->updatedAt = ['from' => $from, 'to' => $to]; + return $this; + } + + /** + * Получить фильтр по дате обновления + * + * @return array|null + */ + public function getUpdatedAt(): ?array + { + return $this->updatedAt; + } + + /** + * Установить фильтр по дате закрытия + * + * @param int|null $from Начало периода (timestamp) + * @param int|null $to Конец периода (timestamp) + * @return self + */ + public function setClosedAt(?int $from, ?int $to = null): self + { + $this->closedAt = ['from' => $from, 'to' => $to]; + return $this; + } + + /** + * Получить фильтр по дате закрытия + * + * @return array|null + */ + public function getClosedAt(): ?array + { + return $this->closedAt; + } + + /** + * Установить фильтр по воронкам + * + * @param array $pipelineIds + * @return self + */ + public function setPipelineIds(array $pipelineIds): self + { + $this->pipelineIds = $pipelineIds; + return $this; + } + + /** + * @return array + */ + public function getPipelineIds(): array + { + return $this->pipelineIds; + } + + /** + * Установить фильтр по статусам + * + * @param array $statusIds + * @return self + */ + public function setStatusIds(array $statusIds): self + { + $this->statusIds = $statusIds; + return $this; + } + + /** + * @return array + */ + public function getStatusIds(): array + { + return $this->statusIds; + } + + /** + * Установить фильтр по ответственным + * + * @param array $userIds + * @return self + */ + public function setResponsibleUserIds(array $userIds): self + { + $this->responsibleUserIds = $userIds; + return $this; + } + + /** + * @return array + */ + public function getResponsibleUserIds(): array + { + return $this->responsibleUserIds; + } + + /** + * Установить фильтр по источникам + * + * @param array $sourceIds + * @return self + */ + public function setSourceIds(array $sourceIds): self + { + $this->sourceIds = $sourceIds; + return $this; + } + + /** + * @return array + */ + public function getSourceIds(): array + { + return $this->sourceIds; + } + + /** + * Установить фильтр по цене + * + * @param int|float|null $from Минимальная цена + * @param int|float|null $to Максимальная цена + * @return self + */ + public function setPriceRange($from = null, $to = null): self + { + $this->priceRange = ['from' => $from, 'to' => $to]; + return $this; + } + + /** + * @return array|null + */ + public function getPriceRange(): ?array + { + return $this->priceRange; + } + + /** + * @param bool|null $isDeleted + * @return self + */ + public function setIsDeleted(?bool $isDeleted): self + { + $this->isDeleted = $isDeleted; + return $this; + } + + /** + * @return bool|null + */ + public function getIsDeleted(): ?bool + { + return $this->isDeleted; + } + + /** + * @param bool|null $isWon + * @return self + */ + public function setIsWon(?bool $isWon): self + { + $this->isWon = $isWon; + return $this; + } + + /** + * @return bool|null + */ + public function getIsWon(): ?bool + { + return $this->isWon; + } + + /** + * @param bool|null $isLost + * @return self + */ + public function setIsLost(?bool $isLost): self + { + $this->isLost = $isLost; + return $this; + } + + /** + * @return bool|null + */ + public function getIsLost(): ?bool + { + return $this->isLost; + } + + /** + * @param int $limit + * @return self + */ + public function setLimit(int $limit): self + { + $this->limit = $limit; + return $this; + } + + /** + * @return int + */ + public function getLimit(): int + { + return $this->limit; + } + + /** + * @param int $page + * @return self + */ + public function setPage(int $page): self + { + $this->page = $page; + return $this; + } + + /** + * @return int + */ + public function getPage(): int + { + return $this->page; + } + + /** + * @param string|null $query + * @return self + */ + public function setQuery(?string $query): self + { + $this->query = $query; + return $this; + } + + /** + * @return string|null + */ + public function getQuery(): ?string + { + return $this->query; + } + + /** + * @param array $ids + * @return self + */ + public function setIds(array $ids): self + { + $this->ids = $ids; + return $this; + } + + /** + * @return array + */ + public function getIds(): array + { + return $this->ids; + } + + /** + * @inheritDoc + */ + public function buildFilter(): array + { + $filter = []; + + if (!empty($this->ids)) { + $filter['id'] = implode(',', $this->ids); + } + + if (!empty($this->pipelineIds)) { + $filter['pipeline_id'] = implode(',', $this->pipelineIds); + } + + if (!empty($this->statusIds)) { + $filter['status_id'] = implode(',', $this->statusIds); + } + + if (!empty($this->responsibleUserIds)) { + $filter['responsible_user_id'] = implode(',', $this->responsibleUserIds); + } + + if (!empty($this->sourceIds)) { + $filter['source_id'] = implode(',', $this->sourceIds); + } + + if (!empty($this->query)) { + $filter['query'] = $this->query; + } + + $filter['limit'] = $this->limit; + $filter['page'] = $this->page; + + // Даты + if ($this->createdAt !== null) { + if ($this->createdAt['from'] !== null) { + $filter['filter[created_at][from]'] = $this->createdAt['from']; + } + if ($this->createdAt['to'] !== null) { + $filter['filter[created_at][to]'] = $this->createdAt['to']; + } + } + + if ($this->updatedAt !== null) { + if ($this->updatedAt['from'] !== null) { + $filter['filter[updated_at][from]'] = $this->updatedAt['from']; + } + if ($this->updatedAt['to'] !== null) { + $filter['filter[updated_at][to]'] = $this->updatedAt['to']; + } + } + + if ($this->closedAt !== null) { + if ($this->closedAt['from'] !== null) { + $filter['filter[closed_at][from]'] = $this->closedAt['from']; + } + if ($this->closedAt['to'] !== null) { + $filter['filter[closed_at][to]'] = $this->closedAt['to']; + } + } + + // Цена + if ($this->priceRange !== null) { + if ($this->priceRange['from'] !== null) { + $filter['filter[price][from]'] = $this->priceRange['from']; + } + if ($this->priceRange['to'] !== null) { + $filter['filter[price][to]'] = $this->priceRange['to']; + } + } + + return $filter; + } + + /** + * Установить период для аналитики (последние N дней) + * + * @param int $days Количество дней + * @return self + */ + public function setLastDays(int $days): self + { + $to = time(); + $from = $to - ($days * 86400); + + $this->setCreatedAt($from, $to); + return $this; + } + + /** + * Установить период текущего месяца + * + * @return self + */ + public function setCurrentMonth(): self + { + $to = time(); + $from = strtotime('first day of this month 00:00:00'); + + $this->setCreatedAt($from, $to); + return $this; + } + + /** + * Установить период предыдущего месяца + * + * @return self + */ + public function setPreviousMonth(): self + { + $from = strtotime('first day of previous month 00:00:00'); + $to = strtotime('last day of previous month 23:59:59'); + + $this->setCreatedAt($from, $to); + return $this; + } + + /** + * Установить фильтр для "только сделки без сделок с источником" + * + * @return self + */ + public function setOnlyWithSource(): self + { + $this->sourceIds = ['not_empty' => true]; + return $this; + } + + /** + * Очистить все фильтры + * + * @return self + */ + public function clear(): self + { + $this->createdAt = null; + $this->updatedAt = null; + $this->closedAt = null; + $this->pipelineIds = []; + $this->statusIds = []; + $this->responsibleUserIds = []; + $this->sourceIds = []; + $this->priceRange = null; + $this->isDeleted = null; + $this->isWon = null; + $this->isLost = null; + $this->query = null; + $this->ids = []; + + return $this; + } +} \ No newline at end of file diff --git a/src/AmoCRM/Analytics/Models/CallFactModel.php b/src/AmoCRM/Analytics/Models/CallFactModel.php new file mode 100644 index 0000000..17cbdcb --- /dev/null +++ b/src/AmoCRM/Analytics/Models/CallFactModel.php @@ -0,0 +1,546 @@ +callUniq; + } + + /** + * @param string|null $id + * @return self + */ + public function setId($id): self + { + $this->callUniq = $id; + return $this; + } + + /** + * @return string|null + */ + public function getCallUniq(): ?string + { + return $this->callUniq; + } + + /** + * @param string|null $callUniq + * @return self + */ + public function setCallUniq(?string $callUniq): self + { + $this->callUniq = $callUniq; + return $this; + } + + /** + * @return int|null + */ + public function getCallId(): ?int + { + return $this->callId; + } + + /** + * @param int|null $callId + * @return self + */ + public function setCallId(?int $callId): self + { + $this->callId = $callId; + return $this; + } + + /** + * @return int|null + */ + public function getDuration(): ?int + { + return $this->duration; + } + + /** + * @param int|null $duration + * @return self + */ + public function setDuration(?int $duration): self + { + $this->duration = $duration; + return $this; + } + + /** + * @return string|null + */ + public function getSource(): ?string + { + return $this->source; + } + + /** + * @param string|null $source + * @return self + */ + public function setSource(?string $source): self + { + $this->source = $source; + return $this; + } + + /** + * @return string|null + */ + public function getLink(): ?string + { + return $this->link; + } + + /** + * @param string|null $link + * @return self + */ + public function setLink(?string $link): self + { + $this->link = $link; + return $this; + } + + /** + * @return string|null + */ + public function getPhone(): ?string + { + return $this->phone; + } + + /** + * @param string|null $phone + * @return self + */ + public function setPhone(?string $phone): self + { + $this->phone = $phone; + return $this; + } + + /** + * @return string|null + */ + public function getCallResult(): ?string + { + return $this->callResult; + } + + /** + * @param string|null $callResult + * @return self + */ + public function setCallResult(?string $callResult): self + { + $this->callResult = $callResult; + return $this; + } + + /** + * @return int|null + */ + public function getCallStatus(): ?int + { + return $this->callStatus; + } + + /** + * @param int|null $callStatus + * @return self + */ + public function setCallStatus(?int $callStatus): self + { + $this->callStatus = $callStatus; + return $this; + } + + /** + * @return string|null + */ + public function getDirection(): ?string + { + return $this->direction; + } + + /** + * @param string|null $direction + * @return self + */ + public function setDirection(?string $direction): self + { + $this->direction = $direction; + return $this; + } + + /** + * @return int|null + */ + public function getEntityId(): ?int + { + return $this->entityId; + } + + /** + * @param int|null $entityId + * @return self + */ + public function setEntityId(?int $entityId): self + { + $this->entityId = $entityId; + return $this; + } + + /** + * @return string|null + */ + public function getEntityType(): ?string + { + return $this->entityType; + } + + /** + * @param string|null $entityType + * @return self + */ + public function setEntityType(?string $entityType): self + { + $this->entityType = $entityType; + return $this; + } + + /** + * @return int|null + */ + public function getResponsibleUserId(): ?int + { + return $this->responsibleUserId; + } + + /** + * @param int|null $responsibleUserId + * @return self + */ + public function setResponsibleUserId(?int $responsibleUserId): self + { + $this->responsibleUserId = $responsibleUserId; + return $this; + } + + /** + * @return int|null + */ + public function getAgentId(): ?int + { + return $this->agentId; + } + + /** + * @param int|null $agentId + * @return self + */ + public function setAgentId(?int $agentId): self + { + $this->agentId = $agentId; + return $this; + } + + /** + * @return int|null + */ + public function getWaitDuration(): ?int + { + return $this->waitDuration; + } + + /** + * @param int|null $waitDuration + * @return self + */ + public function setWaitDuration(?int $waitDuration): self + { + $this->waitDuration = $waitDuration; + return $this; + } + + /** + * @return int|null + */ + public function getTalkDuration(): ?int + { + return $this->talkDuration; + } + + /** + * @param int|null $talkDuration + * @return self + */ + public function setTalkDuration(?int $talkDuration): self + { + $this->talkDuration = $talkDuration; + return $this; + } + + /** + * Проверить, является ли звонок входящим + * + * @return bool + */ + public function isInbound(): bool + { + return $this->direction === CallModel::DIRECTION_INBOUND; + } + + /** + * Проверить, является ли звонок исходящим + * + * @return bool + */ + public function isOutbound(): bool + { + return $this->direction === CallModel::DIRECTION_OUTBOUND; + } + + /** + * Проверить, был ли звонок принят + * + * @return bool + */ + public function isAnswered(): bool + { + return in_array($this->callStatus, [ + CallModel::CALL_STATUS_OK, + CallModel::CALL_STATUS_BUSY, + ], true); + } + + /** + * @inheritDoc + */ + public static function fromArray(array $data): self + { + $model = new self(); + + $model->setCallUniq($data['call_uniq'] ?? $data['uniq'] ?? null); + $model->setCallId($data['call_id'] ?? $data['id'] ?? null); + $model->setDuration($data['duration'] ?? null); + $model->setSource($data['source'] ?? null); + $model->setLink($data['link'] ?? null); + $model->setPhone($data['phone'] ?? null); + $model->setCallResult($data['call_result'] ?? null); + $model->setCallStatus($data['call_status'] ?? null); + $model->setDirection($data['direction'] ?? null); + $model->setEntityId($data['entity_id'] ?? null); + $model->setEntityType($data['entity_type'] ?? null); + $model->setResponsibleUserId($data['responsible_user_id'] ?? null); + $model->setAgentId($data['agent_id'] ?? null); + $model->setWaitDuration($data['wait_duration'] ?? null); + $model->setTalkDuration($data['talk_duration'] ?? null); + $model->setCreatedAt($data['created_at'] ?? null); + $model->setUpdatedAt($data['updated_at'] ?? null); + $model->setAccountId($data['account_id'] ?? null); + + return $model; + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + return [ + 'call_uniq' => $this->callUniq, + 'call_id' => $this->callId, + 'duration' => $this->duration, + 'source' => $this->source, + 'link' => $this->link, + 'phone' => $this->phone, + 'call_result' => $this->callResult, + 'call_status' => $this->callStatus, + 'direction' => $this->direction, + 'entity_id' => $this->entityId, + 'entity_type' => $this->entityType, + 'responsible_user_id' => $this->responsibleUserId, + 'agent_id' => $this->agentId, + 'wait_duration' => $this->waitDuration, + 'talk_duration' => $this->talkDuration, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + 'account_id' => $this->accountId, + ]; + } + + /** + * Создать CallFactModel из API CallModel + * + * @param CallModel $callModel + * @return self + */ + public static function fromApiModel(CallModel $callModel): self + { + $model = new self(); + + $model->setCallUniq($callModel->getUniq()) + ->setCallId($callModel->getId()) + ->setDuration($callModel->getDuration()) + ->setSource($callModel->getSource()) + ->setLink($callModel->getLink()) + ->setPhone($callModel->getPhone()) + ->setCallResult($callModel->getCallResult()) + ->setCallStatus($callModel->getCallStatus()) + ->setDirection($callModel->getDirection()) + ->setResponsibleUserId($callModel->getResponsibleUserId()); + + // Извлекаем информацию о связанной сущности + $entity = $callModel->getEntity(); + if ($entity !== null) { + $model->setEntityId($entity->getId()); + if (method_exists($entity, 'getType')) { + $model->setEntityType($entity->getType()); + } + } + + return $model; + } + + /** + * Создать модель из данных вебхука add_call + * + * @param array $webhookData Данные из вебхука + * @return self + */ + public static function fromWebhook(array $webhookData): self + { + $model = new self(); + + $model->setCallUniq($webhookData['uniq'] ?? null) + ->setDuration($webhookData['duration'] ?? null) + ->setSource($webhookData['source'] ?? null) + ->setLink($webhookData['link'] ?? null) + ->setPhone($webhookData['phone'] ?? null) + ->setCallResult($webhookData['call_result'] ?? null) + ->setCallStatus($webhookData['call_status'] ?? null) + ->setDirection($webhookData['direction'] ?? null) + ->setEntityId($webhookData['entity_id'] ?? null) + ->setEntityType($webhookData['entity_type'] ?? null) + ->setResponsibleUserId($webhookData['responsible_user_id'] ?? null) + ->setAgentId($webhookData['agent_id'] ?? null) + ->setWaitDuration($webhookData['wait_duration'] ?? null) + ->setTalkDuration($webhookData['talk_duration'] ?? null); + + return $model; + } +} \ No newline at end of file diff --git a/src/AmoCRM/Analytics/Models/FirstTouchFactModel.php b/src/AmoCRM/Analytics/Models/FirstTouchFactModel.php new file mode 100644 index 0000000..1b8f8f7 --- /dev/null +++ b/src/AmoCRM/Analytics/Models/FirstTouchFactModel.php @@ -0,0 +1,506 @@ +unsortedUid; + } + + /** + * @param string|null $id + * @return self + */ + public function setId($id): self + { + $this->unsortedUid = $id; + return $this; + } + + /** + * @return string|null + */ + public function getUnsortedUid(): ?string + { + return $this->unsortedUid; + } + + /** + * @param string|null $unsortedUid + * @return self + */ + public function setUnsortedUid(?string $unsortedUid): self + { + $this->unsortedUid = $unsortedUid; + return $this; + } + + /** + * @return string|null + */ + public function getCategory(): ?string + { + return $this->category; + } + + /** + * @param string|null $category + * @return self + */ + public function setCategory(?string $category): self + { + $this->category = $category; + return $this; + } + + /** + * @return string|null + */ + public function getSourceName(): ?string + { + return $this->sourceName; + } + + /** + * @param string|null $sourceName + * @return self + */ + public function setSourceName(?string $sourceName): self + { + $this->sourceName = $sourceName; + return $this; + } + + /** + * @return string|null + */ + public function getSourceUid(): ?string + { + return $this->sourceUid; + } + + /** + * @param string|null $sourceUid + * @return self + */ + public function setSourceUid(?string $sourceUid): self + { + $this->sourceUid = $sourceUid; + return $this; + } + + /** + * @return int|null + */ + public function getPipelineId(): ?int + { + return $this->pipelineId; + } + + /** + * @param int|null $pipelineId + * @return self + */ + public function setPipelineId(?int $pipelineId): self + { + $this->pipelineId = $pipelineId; + return $this; + } + + /** + * @return int|null + */ + public function getLeadId(): ?int + { + return $this->leadId; + } + + /** + * @param int|null $leadId + * @return self + */ + public function setLeadId(?int $leadId): self + { + $this->leadId = $leadId; + return $this; + } + + /** + * @return int|null + */ + public function getContactId(): ?int + { + return $this->contactId; + } + + /** + * @param int|null $contactId + * @return self + */ + public function setContactId(?int $contactId): self + { + $this->contactId = $contactId; + return $this; + } + + /** + * @return bool + */ + public function isAccepted(): bool + { + return $this->isAccepted; + } + + /** + * @param bool $isAccepted + * @return self + */ + public function setIsAccepted(bool $isAccepted): self + { + $this->isAccepted = $isAccepted; + if ($isAccepted) { + $this->isDeclined = false; + } + return $this; + } + + /** + * @return bool + */ + public function isDeclined(): bool + { + return $this->isDeclined; + } + + /** + * @param bool $isDeclined + * @return self + */ + public function setIsDeclined(bool $isDeclined): self + { + $this->isDeclined = $isDeclined; + if ($isDeclined) { + $this->isAccepted = false; + } + return $this; + } + + /** + * @return array|null + */ + public function getMetadata(): ?array + { + return $this->metadata; + } + + /** + * @param array|null $metadata + * @return self + */ + public function setMetadata(?array $metadata): self + { + $this->metadata = $metadata; + return $this; + } + + /** + * @return string|null + */ + public function getClientName(): ?string + { + return $this->clientName; + } + + /** + * @param string|null $clientName + * @return self + */ + public function setClientName(?string $clientName): self + { + $this->clientName = $clientName; + return $this; + } + + /** + * @return string|null + */ + public function getClientPhone(): ?string + { + return $this->clientPhone; + } + + /** + * @param string|null $clientPhone + * @return self + */ + public function setClientPhone(?string $clientPhone): self + { + $this->clientPhone = $clientPhone; + return $this; + } + + /** + * @return string|null + */ + public function getClientEmail(): ?string + { + return $this->clientEmail; + } + + /** + * @param string|null $clientEmail + * @return self + */ + public function setClientEmail(?string $clientEmail): self + { + $this->clientEmail = $clientEmail; + return $this; + } + + /** + * Проверить, является ли обращение "горячим" (sip или чат) + * + * @return bool + */ + public function isHotLead(): bool + { + return in_array($this->category, [ + BaseUnsortedModel::CATEGORY_CODE_SIP, + BaseUnsortedModel::CATEGORY_CODE_CHATS, + ], true); + } + + /** + * Проверить, является ли обращение из формы + * + * @return bool + */ + public function isFromForm(): bool + { + return $this->category === BaseUnsortedModel::CATEGORY_CODE_FORMS; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $data): self + { + $model = new self(); + + $model->setUnsortedUid($data['unsorted_uid'] ?? $data['uid'] ?? null); + $model->setCategory($data['category'] ?? null); + $model->setSourceName($data['source_name'] ?? null); + $model->setSourceUid($data['source_uid'] ?? null); + $model->setPipelineId($data['pipeline_id'] ?? null); + $model->setLeadId($data['lead_id'] ?? null); + $model->setContactId($data['contact_id'] ?? null); + $model->setIsAccepted($data['is_accepted'] ?? false); + $model->setIsDeclined($data['is_declined'] ?? false); + $model->setMetadata($data['metadata'] ?? null); + $model->setClientName($data['client_name'] ?? null); + $model->setClientPhone($data['client_phone'] ?? null); + $model->setClientEmail($data['client_email'] ?? null); + $model->setCreatedAt($data['created_at'] ?? null); + $model->setUpdatedAt($data['updated_at'] ?? null); + $model->setAccountId($data['account_id'] ?? null); + + return $model; + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + return [ + 'unsorted_uid' => $this->unsortedUid, + 'category' => $this->category, + 'source_name' => $this->sourceName, + 'source_uid' => $this->sourceUid, + 'pipeline_id' => $this->pipelineId, + 'lead_id' => $this->leadId, + 'contact_id' => $this->contactId, + 'is_accepted' => $this->isAccepted, + 'is_declined' => $this->isDeclined, + 'metadata' => $this->metadata, + 'client_name' => $this->clientName, + 'client_phone' => $this->clientPhone, + 'client_email' => $this->clientEmail, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + 'account_id' => $this->accountId, + ]; + } + + /** + * Создать FirstTouchFactModel из API Unsorted модели + * + * @param BaseUnsortedModel $unsortedModel + * @return self + */ + public static function fromApiModel(BaseUnsortedModel $unsortedModel): self + { + $model = new self(); + + $model->setUnsortedUid($unsortedModel->getUid()) + ->setCategory($unsortedModel->getCategory()) + ->setSourceName($unsortedModel->getSourceName()) + ->setSourceUid($unsortedModel->getSourceUid()) + ->setPipelineId($unsortedModel->getPipelineId()) + ->setCreatedAt($unsortedModel->getCreatedAt()); + + // Извлекаем информацию о контакте + $contacts = $unsortedModel->getContacts(); + if ($contacts !== null && !$contacts->isEmpty()) { + $contact = $contacts->first(); + if ($contact !== null) { + $model->setContactId($contact->getId()); + $model->setClientName($contact->getName()); + } + } + + // Извлекаем информацию о созданной сделке + $lead = $unsortedModel->getLead(); + if ($lead !== null) { + $model->setLeadId($lead->getId()); + } + + // Извлекаем метаданные + $metadata = $unsortedModel->getMetadata(); + if ($metadata !== null) { + $model->setMetadata($metadata->toArray() ?? []); + } + + return $model; + } + + /** + * Создать модель из данных вебхука + * + * @param array $webhookData Данные из вебхука + * @return self + */ + public static function fromWebhook(array $webhookData): self + { + $model = new self(); + + $model->setUnsortedUid($webhookData['uid'] ?? null) + ->setCategory($webhookData['category'] ?? null) + ->setSourceName($webhookData['source_name'] ?? null) + ->setSourceUid($webhookData['source_uid'] ?? null) + ->setPipelineId($webhookData['pipeline_id'] ?? null) + ->setCreatedAt($webhookData['created_at'] ?? null); + + // Проверяем, было ли обращение принято/отклонено + if (isset($webhookData['action'])) { + $model->setIsAccepted($webhookData['action'] === 'accept'); + $model->setIsDeclined($webhookData['action'] === 'decline'); + } + + // Извлекаем информацию о сделке из embedded данных + if (isset($webhookData['_embedded']['leads'][0])) { + $model->setLeadId($webhookData['_embedded']['leads'][0]['id'] ?? null); + } + + // Извлекаем информацию о контакте + if (isset($webhookData['_embedded']['contacts'][0])) { + $model->setContactId($webhookData['_embedded']['contacts'][0]['id'] ?? null); + } + + return $model; + } +} \ No newline at end of file diff --git a/src/AmoCRM/Analytics/Models/LeadFactModel.php b/src/AmoCRM/Analytics/Models/LeadFactModel.php new file mode 100644 index 0000000..85beaff --- /dev/null +++ b/src/AmoCRM/Analytics/Models/LeadFactModel.php @@ -0,0 +1,647 @@ +leadId; + } + + /** + * @param int|null $id + * @return self + */ + public function setId($id): self + { + $this->leadId = $id; + return $this; + } + + /** + * @return int|null + */ + public function getLeadId(): ?int + { + return $this->leadId; + } + + /** + * @param int|null $leadId + * @return self + */ + public function setLeadId(?int $leadId): self + { + $this->leadId = $leadId; + return $this; + } + + /** + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * @param string|null $name + * @return self + */ + public function setName(?string $name): self + { + $this->name = $name; + return $this; + } + + /** + * @return int|null + */ + public function getPipelineId(): ?int + { + return $this->pipelineId; + } + + /** + * @param int|null $pipelineId + * @return self + */ + public function setPipelineId(?int $pipelineId): self + { + $this->pipelineId = $pipelineId; + return $this; + } + + /** + * @return int|null + */ + public function getStatusId(): ?int + { + return $this->statusId; + } + + /** + * @param int|null $statusId + * @return self + */ + public function setStatusId(?int $statusId): self + { + $this->statusId = $statusId; + $this->updateWinLossFlags(); + return $this; + } + + /** + * @return float|null + */ + public function getPrice(): ?float + { + return $this->price; + } + + /** + * @param float|null $price + * @return self + */ + public function setPrice($price): self + { + $this->price = $price !== null ? (float)$price : null; + return $this; + } + + /** + * @return int|null + */ + public function getClosedAt(): ?int + { + return $this->closedAt; + } + + /** + * @param int|null $closedAt + * @return self + */ + public function setClosedAt(?int $closedAt): self + { + $this->closedAt = $closedAt; + return $this; + } + + /** + * @return int|null + */ + public function getSourceId(): ?int + { + return $this->sourceId; + } + + /** + * @param int|null $sourceId + * @return self + */ + public function setSourceId(?int $sourceId): self + { + $this->sourceId = $sourceId; + return $this; + } + + /** + * @return string|null + */ + public function getSourceName(): ?string + { + return $this->sourceName; + } + + /** + * @param string|null $sourceName + * @return self + */ + public function setSourceName(?string $sourceName): self + { + $this->sourceName = $sourceName; + return $this; + } + + /** + * @return string|null + */ + public function getSourceExternalId(): ?string + { + return $this->sourceExternalId; + } + + /** + * @param string|null $sourceExternalId + * @return self + */ + public function setSourceExternalId(?string $sourceExternalId): self + { + $this->sourceExternalId = $sourceExternalId; + return $this; + } + + /** + * @return int|null + */ + public function getResponsibleUserId(): ?int + { + return $this->responsibleUserId; + } + + /** + * @param int|null $responsibleUserId + * @return self + */ + public function setResponsibleUserId(?int $responsibleUserId): self + { + $this->responsibleUserId = $responsibleUserId; + return $this; + } + + /** + * @return int|null + */ + public function getContactId(): ?int + { + return $this->contactId; + } + + /** + * @param int|null $contactId + * @return self + */ + public function setContactId(?int $contactId): self + { + $this->contactId = $contactId; + return $this; + } + + /** + * @return int|null + */ + public function getCompanyId(): ?int + { + return $this->companyId; + } + + /** + * @param int|null $companyId + * @return self + */ + public function setCompanyId(?int $companyId): self + { + $this->companyId = $companyId; + return $this; + } + + /** + * @return int|null + */ + public function getLossReasonId(): ?int + { + return $this->lossReasonId; + } + + /** + * @param int|null $lossReasonId + * @return self + */ + public function setLossReasonId(?int $lossReasonId): self + { + $this->lossReasonId = $lossReasonId; + return $this; + } + + /** + * @return int|null + */ + public function getScore(): ?int + { + return $this->score; + } + + /** + * @param int|null $score + * @return self + */ + public function setScore(?int $score): self + { + $this->score = $score; + return $this; + } + + /** + * @return bool + */ + public function isDeleted(): bool + { + return $this->isDeleted; + } + + /** + * @param bool $isDeleted + * @return self + */ + public function setIsDeleted(bool $isDeleted): self + { + $this->isDeleted = $isDeleted; + return $this; + } + + /** + * @return bool + */ + public function isWon(): bool + { + return $this->isWon; + } + + /** + * @param bool $isWon + * @return self + */ + public function setIsWon(bool $isWon): self + { + $this->isWon = $isWon; + return $this; + } + + /** + * @return bool + */ + public function isLost(): bool + { + return $this->isLost; + } + + /** + * @param bool $isLost + * @return self + */ + public function setIsLost(bool $isLost): self + { + $this->isLost = $isLost; + return $this; + } + + /** + * @return string|null + */ + public function getVisitorUid(): ?string + { + return $this->visitorUid; + } + + /** + * @param string|null $visitorUid + * @return self + */ + public function setVisitorUid(?string $visitorUid): self + { + $this->visitorUid = $visitorUid; + return $this; + } + + /** + * @return int|null + */ + public function getGroupId(): ?int + { + return $this->groupId; + } + + /** + * @param int|null $groupId + * @return self + */ + public function setGroupId(?int $groupId): self + { + $this->groupId = $groupId; + return $this; + } + + /** + * @return int|null + */ + public function getCreatedBy(): ?int + { + return $this->createdBy; + } + + /** + * @param int|null $createdBy + * @return self + */ + public function setCreatedBy(?int $createdBy): self + { + $this->createdBy = $createdBy; + return $this; + } + + /** + * Обновить флаги isWon/isLost на основе statusId + */ + protected function updateWinLossFlags(): void + { + if ($this->statusId !== null) { + $this->isWon = $this->statusId === LeadModel::WON_STATUS_ID; + $this->isLost = $this->statusId === LeadModel::LOST_STATUS_ID; + } + } + + /** + * @inheritDoc + */ + public static function fromArray(array $data): self + { + $model = new self(); + + $model->setLeadId($data['lead_id'] ?? $data['id'] ?? null); + $model->setName($data['name'] ?? null); + $model->setPipelineId($data['pipeline_id'] ?? null); + $model->setStatusId($data['status_id'] ?? null); + $model->setPrice($data['price'] ?? null); + $model->setCreatedAt($data['created_at'] ?? null); + $model->setUpdatedAt($data['updated_at'] ?? null); + $model->setClosedAt($data['closed_at'] ?? null); + $model->setSourceId($data['source_id'] ?? null); + $model->setSourceName($data['source_name'] ?? null); + $model->setSourceExternalId($data['source_external_id'] ?? null); + $model->setResponsibleUserId($data['responsible_user_id'] ?? null); + $model->setContactId($data['contact_id'] ?? null); + $model->setCompanyId($data['company_id'] ?? null); + $model->setLossReasonId($data['loss_reason_id'] ?? null); + $model->setScore($data['score'] ?? null); + $model->setIsDeleted($data['is_deleted'] ?? false); + $model->setIsWon($data['is_won'] ?? false); + $model->setIsLost($data['is_lost'] ?? false); + $model->setVisitorUid($data['visitor_uid'] ?? null); + $model->setGroupId($data['group_id'] ?? null); + $model->setCreatedBy($data['created_by'] ?? null); + $model->setAccountId($data['account_id'] ?? null); + + return $model; + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + return [ + 'lead_id' => $this->leadId, + 'name' => $this->name, + 'pipeline_id' => $this->pipelineId, + 'status_id' => $this->statusId, + 'price' => $this->price, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + 'closed_at' => $this->closedAt, + 'source_id' => $this->sourceId, + 'source_name' => $this->sourceName, + 'source_external_id' => $this->sourceExternalId, + 'responsible_user_id' => $this->responsibleUserId, + 'contact_id' => $this->contactId, + 'company_id' => $this->companyId, + 'loss_reason_id' => $this->lossReasonId, + 'score' => $this->score, + 'is_deleted' => $this->isDeleted, + 'is_won' => $this->isWon, + 'is_lost' => $this->isLost, + 'visitor_uid' => $this->visitorUid, + 'group_id' => $this->groupId, + 'created_by' => $this->createdBy, + 'account_id' => $this->accountId, + ]; + } + + /** + * Создать LeadFactModel из API LeadModel + * + * @param LeadModel $leadModel + * @return self + */ + public static function fromApiModel(LeadModel $leadModel): self + { + $model = new self(); + + $model->setLeadId($leadModel->getId()) + ->setName($leadModel->getName()) + ->setPipelineId($leadModel->getPipelineId()) + ->setStatusId($leadModel->getStatusId()) + ->setPrice($leadModel->getPrice()) + ->setCreatedAt($leadModel->getCreatedAt()) + ->setUpdatedAt($leadModel->getUpdatedAt()) + ->setClosedAt($leadModel->getClosedAt()) + ->setSourceId($leadModel->getSourceId()) + ->setResponsibleUserId($leadModel->getResponsibleUserId()) + ->setLossReasonId($leadModel->getLossReasonId()) + ->setScore($leadModel->getScore()) + ->setIsDeleted($leadModel->getIsDeleted()) + ->setVisitorUid($leadModel->getVisitorUid()) + ->setGroupId($leadModel->getGroupId()) + ->setCreatedBy($leadModel->getCreatedBy()); + + // Извлекаем информацию об источнике + if ($leadModel->getSource() !== null) { + $model->setSourceName($leadModel->getSource()->getName()); + $model->setSourceExternalId($leadModel->getSource()->getExternalId()); + } + + // Извлекаем основной контакт + $contacts = $leadModel->getContacts(); + if ($contacts !== null && !$contacts->isEmpty()) { + $mainContact = $contacts->getBy('isMain', true) ?? $contacts->first(); + if ($mainContact !== null) { + $model->setContactId($mainContact->getId()); + } + } + + // Извлекаем компанию + $company = $leadModel->getCompany(); + if ($company !== null) { + $model->setCompanyId($company->getId()); + } + + // Обновляем флаги isWon/isLost + $model->updateWinLossFlags(); + + return $model; + } +} \ No newline at end of file diff --git a/src/AmoCRM/Analytics/Models/LeadStatusHistoryModel.php b/src/AmoCRM/Analytics/Models/LeadStatusHistoryModel.php new file mode 100644 index 0000000..41135cf --- /dev/null +++ b/src/AmoCRM/Analytics/Models/LeadStatusHistoryModel.php @@ -0,0 +1,415 @@ +id; + } + + /** + * @param int|null $id + * @return self + */ + public function setId($id): self + { + $this->id = $id; + return $this; + } + + /** + * @return int|null + */ + public function getLeadId(): ?int + { + return $this->leadId; + } + + /** + * @param int|null $leadId + * @return self + */ + public function setLeadId(?int $leadId): self + { + $this->leadId = $leadId; + return $this; + } + + /** + * @return int|null + */ + public function getStatusIdFrom(): ?int + { + return $this->statusIdFrom; + } + + /** + * @param int|null $statusIdFrom + * @return self + */ + public function setStatusIdFrom(?int $statusIdFrom): self + { + $this->statusIdFrom = $statusIdFrom; + $this->updateTransitionFlags(); + return $this; + } + + /** + * @return int|null + */ + public function getStatusIdTo(): ?int + { + return $this->statusIdTo; + } + + /** + * @param int|null $statusIdTo + * @return self + */ + public function setStatusIdTo(?int $statusIdTo): self + { + $this->statusIdTo = $statusIdTo; + $this->updateTransitionFlags(); + return $this; + } + + /** + * @return int|null + */ + public function getPipelineId(): ?int + { + return $this->pipelineId; + } + + /** + * @param int|null $pipelineId + * @return self + */ + public function setPipelineId(?int $pipelineId): self + { + $this->pipelineId = $pipelineId; + return $this; + } + + /** + * @return int|null + */ + public function getChangedBy(): ?int + { + return $this->changedBy; + } + + /** + * @param int|null $changedBy + * @return self + */ + public function setChangedBy(?int $changedBy): self + { + $this->changedBy = $changedBy; + return $this; + } + + /** + * @return int|null + */ + public function getChangedAt(): ?int + { + return $this->changedAt; + } + + /** + * @param int|null $changedAt + * @return self + */ + public function setChangedAt(?int $changedAt): self + { + $this->changedAt = $changedAt; + return $this; + } + + /** + * @return int|null + */ + public function getDurationSeconds(): ?int + { + return $this->durationSeconds; + } + + /** + * @param int|null $durationSeconds + * @return self + */ + public function setDurationSeconds(?int $durationSeconds): self + { + $this->durationSeconds = $durationSeconds; + return $this; + } + + /** + * @return string|null + */ + public function getStatusFromName(): ?string + { + return $this->statusFromName; + } + + /** + * @param string|null $statusFromName + * @return self + */ + public function setStatusFromName(?string $statusFromName): self + { + $this->statusFromName = $statusFromName; + return $this; + } + + /** + * @return string|null + */ + public function getStatusToName(): ?string + { + return $this->statusToName; + } + + /** + * @param string|null $statusToName + * @return self + */ + public function setStatusToName(?string $statusToName): self + { + $this->statusToName = $statusToName; + return $this; + } + + /** + * @return bool + */ + public function isWonTransition(): bool + { + return $this->isWonTransition; + } + + /** + * @param bool $isWonTransition + * @return self + */ + public function setIsWonTransition(bool $isWonTransition): self + { + $this->isWonTransition = $isWonTransition; + return $this; + } + + /** + * @return bool + */ + public function isLostTransition(): bool + { + return $this->isLostTransition; + } + + /** + * @param bool $isLostTransition + * @return self + */ + public function setIsLostTransition(bool $isLostTransition): self + { + $this->isLostTransition = $isLostTransition; + return $this; + } + + /** + * Обновить флаги переходов в победу/проигрыш + */ + protected function updateTransitionFlags(): void + { + if ($this->statusIdTo !== null) { + $this->isWonTransition = $this->statusIdTo === \AmoCRM\Models\LeadModel::WON_STATUS_ID; + $this->isLostTransition = $this->statusIdTo === \AmoCRM\Models\LeadModel::LOST_STATUS_ID; + } + } + + /** + * @inheritDoc + */ + public static function fromArray(array $data): self + { + $model = new self(); + + $model->setId($data['id'] ?? null); + $model->setLeadId($data['lead_id'] ?? null); + $model->setStatusIdFrom($data['status_id_from'] ?? null); + $model->setStatusIdTo($data['status_id_to'] ?? null); + $model->setPipelineId($data['pipeline_id'] ?? null); + $model->setChangedBy($data['changed_by'] ?? null); + $model->setChangedAt($data['changed_at'] ?? null); + $model->setDurationSeconds($data['duration_seconds'] ?? null); + $model->setStatusFromName($data['status_from_name'] ?? null); + $model->setStatusToName($data['status_to_name'] ?? null); + $model->setIsWonTransition($data['is_won_transition'] ?? false); + $model->setIsLostTransition($data['is_lost_transition'] ?? false); + $model->setCreatedAt($data['created_at'] ?? null); + $model->setUpdatedAt($data['updated_at'] ?? null); + $model->setAccountId($data['account_id'] ?? null); + + return $model; + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'lead_id' => $this->leadId, + 'status_id_from' => $this->statusIdFrom, + 'status_id_to' => $this->statusIdTo, + 'pipeline_id' => $this->pipelineId, + 'changed_by' => $this->changedBy, + 'changed_at' => $this->changedAt, + 'duration_seconds' => $this->durationSeconds, + 'status_from_name' => $this->statusFromName, + 'status_to_name' => $this->statusToName, + 'is_won_transition' => $this->isWonTransition, + 'is_lost_transition' => $this->isLostTransition, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + 'account_id' => $this->accountId, + ]; + } + + /** + * Создать LeadStatusHistoryModel из API EventModel + * + * @param EventModel $eventModel + * @param int|null $previousEventTimestamp Время предыдущего события для расчёта duration + * @return self + */ + public static function fromApiModel(EventModel $eventModel, ?int $previousEventTimestamp = null): self + { + $model = new self(); + + $valueBefore = $eventModel->getValueBefore() ?? []; + $valueAfter = $eventModel->getValueAfter() ?? []; + + $model->setLeadId($eventModel->getEntityId()) + ->setChangedBy($eventModel->getCreatedBy()) + ->setChangedAt($eventModel->getCreatedAt()) + ->setStatusIdFrom($valueBefore['status_id'] ?? null) + ->setStatusIdTo($valueAfter['status_id'] ?? null); + + // Рассчитываем duration если есть предыдущее событие + if ($previousEventTimestamp !== null && $eventModel->getCreatedAt() !== null) { + $model->setDurationSeconds($eventModel->getCreatedAt() - $previousEventTimestamp); + } + + return $model; + } + + /** + * Создать модель из данных вебхука + * + * @param array $webhookData Данные из вебхука + * @return self + */ + public static function fromWebhook(array $webhookData): self + { + $model = new self(); + + $model->setLeadId($webhookData['leads']['id'] ?? null) + ->setChangedBy($webhookData['account_id'] ?? null) + ->setChangedAt(time()) + ->setStatusIdFrom($webhookData['leads']['status']['from'] ?? null) + ->setStatusIdTo($webhookData['leads']['status']['to'] ?? null); + + return $model; + } +} \ No newline at end of file diff --git a/src/AmoCRM/Analytics/Models/TransactionFactModel.php b/src/AmoCRM/Analytics/Models/TransactionFactModel.php new file mode 100644 index 0000000..0cdc7f3 --- /dev/null +++ b/src/AmoCRM/Analytics/Models/TransactionFactModel.php @@ -0,0 +1,408 @@ +transactionId; + } + + /** + * @param int|null $id + * @return self + */ + public function setId($id): self + { + $this->transactionId = $id; + return $this; + } + + /** + * @return int|null + */ + public function getTransactionId(): ?int + { + return $this->transactionId; + } + + /** + * @param int|null $transactionId + * @return self + */ + public function setTransactionId(?int $transactionId): self + { + $this->transactionId = $transactionId; + return $this; + } + + /** + * @return int|null + */ + public function getCustomerId(): ?int + { + return $this->customerId; + } + + /** + * @param int|null $customerId + * @return self + */ + public function setCustomerId(?int $customerId): self + { + $this->customerId = $customerId; + return $this; + } + + /** + * @return float|null + */ + public function getPrice(): ?float + { + return $this->price; + } + + /** + * @param float|null $price + * @return self + */ + public function setPrice($price): self + { + $this->price = $price !== null ? (float)$price : null; + return $this; + } + + /** + * @return int|null + */ + public function getCompletedAt(): ?int + { + return $this->completedAt; + } + + /** + * @param int|null $completedAt + * @return self + */ + public function setCompletedAt(?int $completedAt): self + { + $this->completedAt = $completedAt; + return $this; + } + + /** + * @return string|null + */ + public function getComment(): ?string + { + return $this->comment; + } + + /** + * @param string|null $comment + * @return self + */ + public function setComment(?string $comment): self + { + $this->comment = $comment; + return $this; + } + + /** + * @return string|null + */ + public function getExternalId(): ?string + { + return $this->externalId; + } + + /** + * @param string|null $externalId + * @return self + */ + public function setExternalId(?string $externalId): self + { + $this->externalId = $externalId; + return $this; + } + + /** + * @return string|null + */ + public function getReceiptLink(): ?string + { + return $this->receiptLink; + } + + /** + * @param string|null $receiptLink + * @return self + */ + public function setReceiptLink(?string $receiptLink): self + { + $this->receiptLink = $receiptLink; + return $this; + } + + /** + * @return int|null + */ + public function getNextDate(): ?int + { + return $this->nextDate; + } + + /** + * @param int|null $nextDate + * @return self + */ + public function setNextDate(?int $nextDate): self + { + $this->nextDate = $nextDate; + return $this; + } + + /** + * @return float|null + */ + public function getNextPrice(): ?float + { + return $this->nextPrice; + } + + /** + * @param float|null $nextPrice + * @return self + */ + public function setNextPrice($nextPrice): self + { + $this->nextPrice = $nextPrice !== null ? (float)$nextPrice : null; + return $this; + } + + /** + * @return bool + */ + public function isDeleted(): bool + { + return $this->isDeleted; + } + + /** + * @param bool $isDeleted + * @return self + */ + public function setIsDeleted(bool $isDeleted): self + { + $this->isDeleted = $isDeleted; + return $this; + } + + /** + * @return array|null + */ + public function getCatalogElements(): ?array + { + return $this->catalogElements; + } + + /** + * @param array|null $catalogElements + * @return self + */ + public function setCatalogElements(?array $catalogElements): self + { + $this->catalogElements = $catalogElements; + return $this; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $data): self + { + $model = new self(); + + $model->setTransactionId($data['transaction_id'] ?? $data['id'] ?? null); + $model->setCustomerId($data['customer_id'] ?? null); + $model->setPrice($data['price'] ?? null); + $model->setCompletedAt($data['completed_at'] ?? null); + $model->setCreatedAt($data['created_at'] ?? null); + $model->setUpdatedAt($data['updated_at'] ?? null); + $model->setComment($data['comment'] ?? null); + $model->setExternalId($data['external_id'] ?? null); + $model->setReceiptLink($data['receipt_link'] ?? null); + $model->setNextDate($data['next_date'] ?? null); + $model->setNextPrice($data['next_price'] ?? null); + $model->setIsDeleted($data['is_deleted'] ?? false); + $model->setCatalogElements($data['catalog_elements'] ?? null); + $model->setAccountId($data['account_id'] ?? null); + + return $model; + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + return [ + 'transaction_id' => $this->transactionId, + 'customer_id' => $this->customerId, + 'price' => $this->price, + 'completed_at' => $this->completedAt, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + 'comment' => $this->comment, + 'external_id' => $this->externalId, + 'receipt_link' => $this->receiptLink, + 'next_date' => $this->nextDate, + 'next_price' => $this->nextPrice, + 'is_deleted' => $this->isDeleted, + 'catalog_elements' => $this->catalogElements, + 'account_id' => $this->accountId, + ]; + } + + /** + * Создать TransactionFactModel из API TransactionModel + * + * @param TransactionModel $transactionModel + * @return self + */ + public static function fromApiModel(TransactionModel $transactionModel): self + { + $model = new self(); + + $model->setTransactionId($transactionModel->getId()) + ->setCustomerId($transactionModel->getCustomerId()) + ->setPrice($transactionModel->getPrice()) + ->setCompletedAt($transactionModel->getCompletedAt()) + ->setCreatedAt($transactionModel->getCreatedAt()) + ->setUpdatedAt($transactionModel->getUpdatedAt()) + ->setComment($transactionModel->getComment()) + ->setExternalId($transactionModel->getExternalId()) + ->setReceiptLink($transactionModel->getReceiptLink()) + ->setNextDate($transactionModel->getNextDate()) + ->setNextPrice($transactionModel->getNextPrice()) + ->setIsDeleted($transactionModel->getIsDeleted()); + + // Извлекаем информацию о купленных товарах + $catalogElements = $transactionModel->getCatalogElements(); + if ($catalogElements !== null && !$catalogElements->isEmpty()) { + $elements = []; + foreach ($catalogElements as $element) { + $elements[] = [ + 'catalog_id' => $element->getCatalogId(), + 'id' => $element->getId(), + 'quantity' => $element->getQuantity(), + ]; + } + $model->setCatalogElements($elements); + } + + return $model; + } + + /** + * Создать модель из данных вебхука + * + * @param array $webhookData Данные из вебхука + * @return self + */ + public static function fromWebhook(array $webhookData): self + { + $model = new self(); + + $model->setTransactionId($webhookData['id'] ?? null) + ->setCustomerId($webhookData['customer_id'] ?? null) + ->setPrice($webhookData['price'] ?? null) + ->setCompletedAt($webhookData['completed_at'] ?? null) + ->setComment($webhookData['comment'] ?? null) + ->setExternalId($webhookData['external_id'] ?? null) + ->setIsDeleted($webhookData['is_deleted'] ?? false); + + return $model; + } +} \ No newline at end of file diff --git a/src/AmoCRM/Analytics/Services/BaseExportService.php b/src/AmoCRM/Analytics/Services/BaseExportService.php new file mode 100644 index 0000000..ec9ff90 --- /dev/null +++ b/src/AmoCRM/Analytics/Services/BaseExportService.php @@ -0,0 +1,390 @@ +apiClient = $apiClient; + } + + /** + * Получить API-клиент + * + * @return AmoCRMApiClient + */ + public function getApiClient(): AmoCRMApiClient + { + return $this->apiClient; + } + + /** + * Получить timestamp последней синхронизации + * + * @return int|null + */ + public function getLastSyncTimestamp(): ?int + { + return $this->lastSyncTimestamp; + } + + /** + * Установить timestamp последней синхронизации + * + * @param int $timestamp + * @return self + */ + public function setLastSyncTimestamp(int $timestamp): self + { + $this->lastSyncTimestamp = $timestamp; + return $this; + } + + /** + * Получить максимальное количество страниц + * + * @return int + */ + public function getMaxPages(): int + { + return $this->maxPages; + } + + /** + * Установить максимальное количество страниц + * + * @param int $maxPages + * @return self + */ + public function setMaxPages(int $maxPages): self + { + $this->maxPages = $maxPages; + return $this; + } + + /** + * Получить размер страницы по умолчанию + * + * @return int + */ + public function getDefaultPageSize(): int + { + return $this->defaultPageSize; + } + + /** + * Установить размер страницы по умолчанию + * + * @param int $pageSize + * @return self + */ + public function setDefaultPageSize(int $pageSize): self + { + $this->defaultPageSize = $pageSize; + return $this; + } + + /** + * Получить лог ошибок + * + * @return array + */ + public function getErrorLog(): array + { + return $this->errorLog; + } + + /** + * Очистить лог ошибок + * + * @return self + */ + public function clearErrorLog(): self + { + $this->errorLog = []; + return $this; + } + + /** + * Добавить ошибку в лог + * + * @param string $message + * @param array $context + * @return self + */ + protected function logError(string $message, array $context = []): self + { + $this->errorLog[] = [ + 'timestamp' => time(), + 'message' => $message, + 'context' => $context, + ]; + return $this; + } + + /** + * Проверить, была ли уже обработана запись + * + * @param int|string $id + * @return bool + */ + public function isProcessed($id): bool + { + return isset($this->processedIds[$id]); + } + + /** + * Пометить запись как обработанную + * + * @param int|string $id + * @return self + */ + public function markAsProcessed($id): self + { + $this->processedIds[$id] = true; + return $this; + } + + /** + * Очистить список обработанных ID + * + * @return self + */ + public function clearProcessedIds(): self + { + $this->processedIds = []; + return $this; + } + + /** + * Получить количество обработанных записей + * + * @return int + */ + public function getProcessedCount(): int + { + return count($this->processedIds); + } + + /** + * Получить все данные с автоматической пагинацией + * + * @param BaseEntityFilter|null $filter + * @param array $with + * @return array Массив обработанных данных + * @throws AmoCRMApiException + */ + protected function fetchAllPages(?BaseEntityFilter $filter = null, array $with = []): array + { + $service = $this->getEntityService(); + $results = []; + $pageCount = 0; + + // Если сервис поддерживает пагинацию + if ($service instanceof HasPageMethodsInterface) { + $collection = $service->get($filter, $with); + + while ($collection !== null && !$collection->isEmpty() && $pageCount < $this->maxPages) { + $pageCount++; + + // Обрабатываем элементы страницы + foreach ($collection as $item) { + $id = $item->getId(); + if (!$this->isProcessed($id)) { + $processedItem = $this->processItem($item); + if ($processedItem !== null) { + $results[] = $processedItem; + } + $this->markAsProcessed($id); + } + } + + // Переходим к следующей странице + $collection = $service->nextPage($collection); + } + } else { + // Сервис без пагинации + $collection = $service->get($filter, $with); + + if ($collection !== null && !$collection->isEmpty()) { + foreach ($collection as $item) { + $id = $item->getId(); + if (!$this->isProcessed($id)) { + $processedItem = $this->processItem($item); + if ($processedItem !== null) { + $results[] = $processedItem; + } + $this->markAsProcessed($id); + } + } + } + } + + return $results; + } + + /** + * Получить данные, обновлённые с момента последней синхронизации + * + * @param int|null $sinceTimestamp Timestamp начала периода + * @param BaseEntityFilter|null $baseFilter Базовый фильтр + * @param array $with Дополнительные связи + * @return array Массив обработанных данных + * @throws AmoCRMApiException + */ + protected function fetchSince(?int $sinceTimestamp = null, ?BaseEntityFilter $baseFilter = null, array $with = []): array + { + if ($sinceTimestamp !== null) { + $this->setLastSyncTimestamp($sinceTimestamp); + } + + // Если есть timestamp последней синхронизации, добавляем фильтр по updatedAt + if ($this->lastSyncTimestamp !== null && $baseFilter !== null) { + $baseFilter = $this->applyUpdatedAtFilter($baseFilter, $this->lastSyncTimestamp); + } + + return $this->fetchAllPages($baseFilter, $with); + } + + /** + * Применить фильтр по updatedAt к базовому фильтру + * Переопределяется в наследниках для разных типов фильтров + * + * @param BaseEntityFilter $filter + * @param int $timestamp + * @return BaseEntityFilter + */ + protected function applyUpdatedAtFilter(BaseEntityFilter $filter, int $timestamp): BaseEntityFilter + { + // По умолчанию пытаемся установить updatedAt + if (method_exists($filter, 'setUpdatedAt')) { + $filter->setUpdatedAt($timestamp, null); + } + return $filter; + } + + /** + * Обработать один элемент коллекции + * Переопределяется в наследниках для преобразования в аналитическую модель + * + * @param mixed $item + * @return array|null + */ + protected function processItem($item): ?array + { + if ($item !== null && method_exists($item, 'toArray')) { + return $item->toArray(); + } + return null; + } + + /** + * Получить сервис сущности + * Переопределяется в наследниках + * + * @return mixed + */ + abstract protected function getEntityService(); + + /** + * Выполнить экспорт с retry при ошибках + * + * @param int $maxRetries Максимальное количество попыток + * @param int $retryDelay Задержка между попытками в секундах + * @param BaseEntityFilter|null $filter + * @param array $with + * @return array + */ + public function exportWithRetry(int $maxRetries = 3, int $retryDelay = 5, ?BaseEntityFilter $filter = null, array $with = []): array + { + $attempts = 0; + $lastError = null; + + while ($attempts < $maxRetries) { + try { + $attempts++; + return $this->fetchAllPages($filter, $with); + } catch (AmoCRMApiException $e) { + $lastError = $e; + $this->logError("Export attempt {$attempts} failed: " . $e->getMessage(), [ + 'filter' => $filter !== null ? get_class($filter) : null, + 'with' => $with, + ]); + + if ($attempts < $maxRetries) { + sleep($retryDelay); + $retryDelay *= 2; // Экспоненциальная задержка + } + } + } + + // После всех попыток бросаем последнее исключение + throw $lastError; + } + + /** + * Получить статистику экспорта + * + * @return array + */ + public function getExportStats(): array + { + return [ + 'processed_count' => $this->getProcessedCount(), + 'last_sync_timestamp' => $this->lastSyncTimestamp, + 'error_count' => count($this->errorLog), + 'max_pages' => $this->maxPages, + 'default_page_size' => $this->defaultPageSize, + ]; + } +} \ No newline at end of file diff --git a/src/AmoCRM/Analytics/Services/EventExportService.php b/src/AmoCRM/Analytics/Services/EventExportService.php new file mode 100644 index 0000000..9ea1180 --- /dev/null +++ b/src/AmoCRM/Analytics/Services/EventExportService.php @@ -0,0 +1,354 @@ +apiClient->events(); + } + + /** + * Создать фильтр событий + * + * @param int|null $sinceTimestamp Начало периода + * @param int|null $untilTimestamp Конец периода + * @param array $entityTypes Типы сущностей (leads, contacts, etc.) + * @param array $eventTypes Типы событий + * @return EventsFilter + */ + public function createFilter( + ?int $sinceTimestamp = null, + ?int $untilTimestamp = null, + array $entityTypes = ['leads'], + array $eventTypes = [] + ): EventsFilter { + $filter = new EventsFilter(); + + if ($sinceTimestamp !== null) { + $filter->setCreatedAt($sinceTimestamp, $untilTimestamp); + } + + if (!empty($entityTypes)) { + $filter->setEntity($entityTypes); + } + + if (!empty($eventTypes)) { + $filter->setTypes($eventTypes); + } + + return $filter; + } + + /** + * Получить историю изменений статусов сделок + * + * @param int|null $sinceTimestamp Начало периода + * @param int|null $untilTimestamp Конец периода + * @param array $leadIds Фильтр по ID сделок (пустой = все) + * @return array Массив переходов по статусам + * @throws AmoCRMApiException + */ + public function getLeadStatusHistory( + ?int $sinceTimestamp = null, + ?int $untilTimestamp = null, + array $leadIds = [] + ): array { + $filter = $this->createFilter( + $sinceTimestamp, + $untilTimestamp, + ['leads'], + [self::TYPE_LEAD_STATUS_CHANGED] + ); + + if (!empty($leadIds)) { + $filter->setEntityIds($leadIds); + } + + return $this->getAllEventsAsStatusHistory($filter); + } + + /** + * Получить все события как историю статусов + * + * @param EventsFilter $filter + * @return array + * @throws AmoCRMApiException + */ + protected function getAllEventsAsStatusHistory(EventsFilter $filter): array + { + $service = $this->getEntityService(); + $results = []; + + /** @var EventsCollections $collection */ + $collection = $service->get($filter); + + $this->previousEventTimestamps = []; + + while ($collection !== null && !$collection->isEmpty()) { + foreach ($collection as $event) { + /** @var EventModel $event */ + if ($event->getType() === self::TYPE_LEAD_STATUS_CHANGED) { + $leadId = $event->getEntityId(); + $previousTimestamp = $this->previousEventTimestamps[$leadId] ?? null; + + $historyItem = \AmoCRM\Analytics\Models\LeadStatusHistoryModel::fromApiModel( + $event, + $previousTimestamp + )->toArray(); + + $results[] = $historyItem; + + // Сохраняем timestamp для расчёта duration + $this->previousEventTimestamps[$leadId] = $event->getCreatedAt(); + } + } + + // Пагинация + if ($service instanceof \AmoCRM\EntitiesServices\Interfaces\HasPageMethodsInterface) { + $collection = $service->nextPage($collection); + } else { + break; + } + } + + return $results; + } + + /** + * Получить все события изменений статусов для расчёта времени на этапах + * + * @param int $leadId ID сделки + * @return array + * @throws AmoCRMApiException + */ + public function getLeadStatusTransitions(int $leadId): array + { + $filter = $this->createFilter(null, null, ['leads'], [self::TYPE_LEAD_STATUS_CHANGED]); + $filter->setEntityIds([$leadId]); + + return $this->getAllEventsAsStatusHistory($filter); + } + + /** + * Рассчитать среднее время нахождения на каждом этапе + * + * @param int $pipelineId ID воронки + * @param int|null $sinceTimestamp Начало периода + * @param int|null $untilTimestamp Конец периода + * @return array [status_id => avg_duration_seconds] + * @throws AmoCRMApiException + */ + public function calculateAverageStageDuration(int $pipelineId, ?int $sinceTimestamp = null, ?int $untilTimestamp = null): array + { + $transitions = $this->getLeadStatusHistory($sinceTimestamp, $untilTimestamp); + + $durationsByStatus = []; + $countByStatus = []; + + foreach ($transitions as $transition) { + $statusId = $transition['status_id_from']; + $duration = $transition['duration_seconds'] ?? null; + + if ($duration !== null && $duration > 0) { + if (!isset($durationsByStatus[$statusId])) { + $durationsByStatus[$statusId] = 0; + $countByStatus[$statusId] = 0; + } + $durationsByStatus[$statusId] += $duration; + $countByStatus[$statusId]++; + } + } + + $averages = []; + foreach ($durationsByStatus as $statusId => $totalDuration) { + $averages[$statusId] = $countByStatus[$statusId] > 0 + ? $totalDuration / $countByStatus[$statusId] + : 0; + } + + return $averages; + } + + /** + * Получить все события сделки + * + * @param int $leadId ID сделки + * @param int|null $sinceTimestamp Начало периода + * @return array + * @throws AmoCRMApiException + */ + public function getLeadEvents(int $leadId, ?int $sinceTimestamp = null): array + { + $filter = new EventsFilter(); + $filter->setEntity(['leads']); + $filter->setEntityIds([$leadId]); + + if ($sinceTimestamp !== null) { + $filter->setCreatedAt($sinceTimestamp, null); + } + + $service = $this->getEntityService(); + $results = []; + + /** @var EventsCollections $collection */ + $collection = $service->get($filter); + + while ($collection !== null && !$collection->isEmpty()) { + foreach ($collection as $event) { + /** @var EventModel $event */ + $results[] = $event->toArray(); + } + + if ($service instanceof \AmoCRM\EntitiesServices\Interfaces\HasPageMethodsInterface) { + $collection = $service->nextPage($collection); + } else { + break; + } + } + + return $results; + } + + /** + * Получить статистику событий за период + * + * @param int $startTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @param array $entityTypes Типы сущностей + * @return array + * @throws AmoCRMApiException + */ + public function getEventStats(int $startTimestamp, ?int $endTimestamp = null, array $entityTypes = ['leads']): array + { + $filter = $this->createFilter($startTimestamp, $endTimestamp, $entityTypes); + + $service = $this->getEntityService(); + $stats = [ + 'total_count' => 0, + 'by_type' => [], + 'by_entity' => [], + ]; + + /** @var EventsCollections $collection */ + $collection = $service->get($filter); + + while ($collection !== null && !$collection->isEmpty()) { + foreach ($collection as $event) { + /** @var EventModel $event */ + $stats['total_count']++; + + $eventType = $event->getType(); + if (!isset($stats['by_type'][$eventType])) { + $stats['by_type'][$eventType] = 0; + } + $stats['by_type'][$eventType]++; + + $entityType = $event->getEntityType(); + if (!isset($stats['by_entity'][$entityType])) { + $stats['by_entity'][$entityType] = 0; + } + $stats['by_entity'][$entityType]++; + } + + if ($service instanceof \AmoCRM\EntitiesServices\Interfaces\HasPageMethodsInterface) { + $collection = $service->nextPage($collection); + } else { + break; + } + } + + return $stats; + } + + /** + * Применить фильтр по createdAt к базовому фильтру + * + * @param EventsFilter $filter + * @param int $timestamp + * @return EventsFilter + */ + protected function applyUpdatedAtFilter($filter, int $timestamp): EventsFilter + { + $filter->setCreatedAt($timestamp, null); + return $filter; + } + + /** + * Обработать один элемент коллекции + * + * @param EventModel $item + * @return array|null + */ + protected function processItem($item): ?array + { + if ($item === null || !($item instanceof EventModel)) { + return null; + } + + return $item->toArray(); + } +} \ No newline at end of file diff --git a/src/AmoCRM/Analytics/Services/FunnelAnalyticsService.php b/src/AmoCRM/Analytics/Services/FunnelAnalyticsService.php new file mode 100644 index 0000000..4e3b4ee --- /dev/null +++ b/src/AmoCRM/Analytics/Services/FunnelAnalyticsService.php @@ -0,0 +1,376 @@ +apiClient = $apiClient; + $this->leadExportService = new LeadExportService($apiClient); + $this->eventExportService = new EventExportService($apiClient); + } + + /** + * Получить структуру воронок + * + * @return array + */ + public function getPipelinesStructure(): array + { + if ($this->pipelinesCache !== null) { + return $this->pipelinesCache; + } + + $pipelines = $this->apiClient->pipelines()->get(); + $this->pipelinesCache = []; + + /** @var PipelineModel $pipeline */ + foreach ($pipelines as $pipeline) { + $pipelineId = $pipeline->getId(); + + $this->pipelinesCache[$pipelineId] = [ + 'id' => $pipelineId, + 'name' => $pipeline->getName(), + 'is_main' => $pipeline->getIsMain(), + 'statuses' => [], + ]; + + /** @var StatusModel $status */ + foreach ($pipeline->getStatuses() as $status) { + $this->pipelinesCache[$pipelineId]['statuses'][$status->getId()] = [ + 'id' => $status->getId(), + 'name' => $status->getName(), + 'sort' => $status->getSort(), + 'color' => $status->getColor(), + 'type' => $status->getType(), + ]; + } + } + + return $this->pipelinesCache; + } + + /** + * Рассчитать статистику воронки за период + * + * @param int $startTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @param int|null $pipelineId ID воронки (null = все) + * @return array + */ + public function calculateFunnelStats(int $startTimestamp, ?int $endTimestamp = null, ?int $pipelineId = null): array + { + $filter = $this->leadExportService->createFilter($startTimestamp, $endTimestamp); + + if ($pipelineId !== null) { + $filter->setPipelineIds([$pipelineId]); + } + + $leads = $this->leadExportService->getAllLeads($filter, [ + LeadModel::CONTACTS, + LeadModel::COMPANY, + ]); + + $pipelines = $this->getPipelinesStructure(); + $stats = []; + + // Инициализация статусов + foreach ($pipelines as $pId => $pipeline) { + $stats[$pId] = [ + 'pipeline_name' => $pipeline['name'], + 'statuses' => [], + 'total_count' => 0, + 'won_count' => 0, + 'lost_count' => 0, + 'total_price' => 0, + 'won_price' => 0, + ]; + + foreach ($pipeline['statuses'] as $sId => $status) { + $stats[$pId]['statuses'][$sId] = [ + 'name' => $status['name'], + 'count' => 0, + 'price' => 0, + 'conversion_from_prev' => 0, + ]; + } + } + + // Подсчёт сделок + foreach ($leads as $lead) { + /** @var LeadModel $lead */ + $pId = $lead->getPipelineId(); + $sId = $lead->getStatusId(); + $price = $lead->getPrice() ?? 0; + + if (!isset($stats[$pId])) { + continue; + } + + $stats[$pId]['total_count']++; + $stats[$pId]['total_price'] += $price; + + if (isset($stats[$pId]['statuses'][$sId])) { + $stats[$pId]['statuses'][$sId]['count']++; + $stats[$pId]['statuses'][$sId]['price'] += $price; + } + + if ($lead->getStatusId() === LeadModel::WON_STATUS_ID) { + $stats[$pId]['won_count']++; + $stats[$pId]['won_price'] += $price; + } elseif ($lead->getStatusId() === LeadModel::LOST_STATUS_ID) { + $stats[$pId]['lost_count']++; + } + } + + // Расчёт конверсии + foreach ($stats as $pId => &$pipelineStats) { + $prevCount = 0; + foreach ($pipelineStats['statuses'] as $sId => &$statusStats) { + if ($prevCount > 0) { + $statusStats['conversion_from_prev'] = ($statusStats['count'] / $prevCount) * 100; + } + $prevCount = $statusStats['count']; + } + } + + return $stats; + } + + /** + * Рассчитать конверсию из первого статуса в победу + * + * @param int $pipelineId ID воронки + * @param int $startTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @return array + */ + public function calculateConversionRate(int $pipelineId, int $startTimestamp, ?int $endTimestamp = null): array + { + $filter = $this->leadExportService->createFilter($startTimestamp, $endTimestamp, [$pipelineId]); + $leads = $this->leadExportService->getAllLeads($filter, []); + + $pipelines = $this->getPipelinesStructure(); + + if (!isset($pipelines[$pipelineId])) { + return []; + } + + $firstStatusId = null; + $statuses = $pipelines[$pipelineId]['statuses']; + uasort($statuses, function ($a, $b) { + return $a['sort'] - $b['sort']; + }); + $firstStatusId = array_key_first($statuses); + + $totalLeads = 0; + $wonLeads = 0; + $byStatus = []; + + foreach ($leads as $lead) { + /** @var LeadModel $lead */ + $totalLeads++; + $statusId = $lead->getStatusId(); + + if (!isset($byStatus[$statusId])) { + $byStatus[$statusId] = [ + 'name' => $statuses[$statusId]['name'] ?? 'Unknown', + 'count' => 0, + 'won_count' => 0, + ]; + } + + $byStatus[$statusId]['count']++; + + if ($lead->getStatusId() === LeadModel::WON_STATUS_ID) { + $wonLeads++; + $byStatus[$statusId]['won_count']++; + } + } + + return [ + 'pipeline_id' => $pipelineId, + 'pipeline_name' => $pipelines[$pipelineId]['name'], + 'total_leads' => $totalLeads, + 'won_leads' => $wonLeads, + 'conversion_rate' => $totalLeads > 0 ? ($wonLeads / $totalLeads) * 100 : 0, + 'by_status' => $byStatus, + ]; + } + + /** + * Рассчитать среднее время нахождения на каждом этапе + * + * @param int $pipelineId ID воронки + * @param int $startTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @return array + */ + public function calculateAverageTimeOnStage(int $pipelineId, int $startTimestamp, ?int $endTimestamp = null): array + { + $transitions = $this->eventExportService->getLeadStatusHistory($startTimestamp, $endTimestamp); + + $pipelines = $this->getPipelinesStructure(); + + if (!isset($pipelines[$pipelineId])) { + return []; + } + + $statuses = $pipelines[$pipelineId]['statuses']; + $durations = []; + + foreach ($transitions as $transition) { + $statusId = $transition['status_id_from'] ?? null; + $duration = $transition['duration_seconds'] ?? null; + + if ($statusId !== null && $duration !== null && $duration > 0 && $duration < 86400 * 30) { + // Игнорируем аномально длинные периоды (> 30 дней) + if (!isset($durations[$statusId])) { + $durations[$statusId] = [ + 'name' => $statuses[$statusId]['name'] ?? 'Unknown', + 'total' => 0, + 'count' => 0, + ]; + } + $durations[$statusId]['total'] += $duration; + $durations[$statusId]['count']++; + } + } + + $result = []; + foreach ($durations as $statusId => $data) { + $result[$statusId] = [ + 'name' => $data['name'], + 'avg_seconds' => $data['count'] > 0 ? $data['total'] / $data['count'] : 0, + 'avg_hours' => $data['count'] > 0 ? ($data['total'] / $data['count']) / 3600 : 0, + 'count' => $data['count'], + ]; + } + + return $result; + } + + /** + * Рассчитать средний чек по воронке + * + * @param int $pipelineId ID воронки + * @param int $startTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @return array + */ + public function calculateAverageDealSize(int $pipelineId, int $startTimestamp, ?int $endTimestamp = null): array + { + $filter = $this->leadExportService->createFilter($startTimestamp, $endTimestamp, [$pipelineId], [LeadModel::WON_STATUS_ID]); + $leads = $this->leadExportService->getAllLeads($filter, []); + + $prices = []; + $totalPrice = 0; + $wonCount = 0; + + foreach ($leads as $lead) { + /** @var LeadModel $lead */ + $price = $lead->getPrice() ?? 0; + if ($price > 0) { + $prices[] = $price; + $totalPrice += $price; + $wonCount++; + } + } + + sort($prices); + $median = count($prices) > 0 ? $prices[(int)(count($prices) / 2)] : 0; + + return [ + 'pipeline_id' => $pipelineId, + 'total_revenue' => $totalPrice, + 'won_count' => $wonCount, + 'average_check' => $wonCount > 0 ? $totalPrice / $wonCount : 0, + 'median_check' => $median, + 'min_check' => count($prices) > 0 ? min($prices) : 0, + 'max_check' => count($prices) > 0 ? max($prices) : 0, + ]; + } + + /** + * Получить топ сделок за период + * + * @param int $startTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @param int $limit Количество сделок + * @param bool $byPrice Сортировка по цене (false = по score) + * @return array + */ + public function getTopDeals(int $startTimestamp, ?int $endTimestamp = null, int $limit = 10, bool $byPrice = true): array + { + $filter = $this->leadExportService->createFilter($startTimestamp, $endTimestamp); + $filter->setLimit(500); // Получаем больше для сортировки + + $leads = $this->leadExportService->getAllLeads($filter, [LeadModel::CONTACTS]); + + $result = []; + foreach ($leads as $lead) { + /** @var LeadModel $lead */ + $result[] = [ + 'id' => $lead->getId(), + 'name' => $lead->getName(), + 'price' => $lead->getPrice(), + 'score' => $lead->getScore(), + 'status_id' => $lead->getStatusId(), + 'responsible_user_id' => $lead->getResponsibleUserId(), + 'created_at' => $lead->getCreatedAt(), + 'is_won' => $lead->getStatusId() === LeadModel::WON_STATUS_ID, + ]; + } + + usort($result, function ($a, $b) use ($byPrice) { + if ($byPrice) { + return ($b['price'] ?? 0) - ($a['price'] ?? 0); + } + return ($b['score'] ?? 0) - ($a['score'] ?? 0); + }); + + return array_slice($result, 0, $limit); + } +} \ No newline at end of file diff --git a/src/AmoCRM/Analytics/Services/LeadExportService.php b/src/AmoCRM/Analytics/Services/LeadExportService.php new file mode 100644 index 0000000..df6e22e --- /dev/null +++ b/src/AmoCRM/Analytics/Services/LeadExportService.php @@ -0,0 +1,357 @@ +apiClient->leads(); + } + + /** + * Получить фильтр сделок + * + * @param int|null $sinceTimestamp Начало периода (updatedAt) + * @param int|null $untilTimestamp Конец периода + * @param array $pipelineIds Фильтр по ID воронок + * @param array $statusIds Фильтр по ID статусов + * @return LeadsFilter + */ + public function createFilter( + ?int $sinceTimestamp = null, + ?int $untilTimestamp = null, + array $pipelineIds = [], + array $statusIds = [] + ): LeadsFilter { + $filter = new LeadsFilter(); + $filter->setLimit($this->defaultPageSize); + + if ($sinceTimestamp !== null) { + $filter->setUpdatedAt($sinceTimestamp, $untilTimestamp); + } + + if (!empty($pipelineIds)) { + $filter->setPipelineIds($pipelineIds); + } + + if (!empty($statusIds)) { + $filter->setStatuses($statusIds); + } + + return $filter; + } + + /** + * Получить все сделки с заданными фильтрами + * + * @param LeadsFilter|null $filter + * @param array|null $with Связи для загрузки + * @return LeadsCollection + * @throws AmoCRMApiException + */ + public function getAllLeads(?LeadsFilter $filter = null, ?array $with = null): LeadsCollection + { + if ($filter === null) { + $filter = new LeadsFilter(); + $filter->setLimit($this->defaultPageSize); + } + + $with = $with ?? $this->defaultWith; + + /** @var LeadsCollection $collection */ + $collection = $this->getEntityService()->get($filter, $with); + + // Собираем все страницы + $service = $this->getEntityService(); + while ($service instanceof \AmoCRM\EntitiesServices\Interfaces\HasPageMethodsInterface && $collection->count() > 0) { + $collection = $service->nextPage($collection); + } + + return $collection; + } + + /** + * Получить сделки, обновлённые с момента последней синхронизации + * + * @param int|null $sinceTimestamp Timestamp начала периода + * @param array $with Связи для загрузки + * @return LeadsCollection + * @throws AmoCRMApiException + */ + public function getLeadsUpdatedSince(?int $sinceTimestamp = null, array $with = []): LeadsCollection + { + $with = empty($with) ? $this->defaultWith : $with; + + $filter = new LeadsFilter(); + $filter->setLimit($this->defaultPageSize); + + if ($sinceTimestamp !== null) { + $filter->setUpdatedAt($sinceTimestamp, null); + $this->setLastSyncTimestamp($sinceTimestamp); + } + + return $this->getAllLeads($filter, $with); + } + + /** + * Получить сделки за период + * + * @param int $startTimestamp Начало периода (createdAt) + * @param int|null $endTimestamp Конец периода + * @param array $with Связи для загрузки + * @return LeadsCollection + * @throws AmoCRMApiException + */ + public function getLeadsCreatedBetween(int $startTimestamp, ?int $endTimestamp = null, array $with = []): LeadsCollection + { + $with = empty($with) ? $this->defaultWith : $with; + + $filter = new LeadsFilter(); + $filter->setLimit($this->defaultPageSize); + $filter->setCreatedAt($startTimestamp, $endTimestamp); + + return $this->getAllLeads($filter, $with); + } + + /** + * Получить сделки по ID воронки + * + * @param int $pipelineId ID воронки + * @param int|null $sinceTimestamp Начало периода + * @param array $with Связи для загрузки + * @return LeadsCollection + * @throws AmoCRMApiException + */ + public function getLeadsByPipeline(int $pipelineId, ?int $sinceTimestamp = null, array $with = []): LeadsCollection + { + $with = empty($with) ? $this->defaultWith : $with; + + $filter = $this->createFilter($sinceTimestamp, null, [$pipelineId]); + + return $this->getAllLeads($filter, $with); + } + + /** + * Получить побеждённые сделки за период + * + * @param int $startTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @param array $with Связи для загрузки + * @return LeadsCollection + * @throws AmoCRMApiException + */ + public function getWonLeads(int $startTimestamp, ?int $endTimestamp = null, array $with = []): LeadsCollection + { + return $this->getLeadsByStatus(LeadModel::WON_STATUS_ID, $startTimestamp, $endTimestamp, $with); + } + + /** + * Получить проигранные сделки за период + * + * @param int $startTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @param array $with Связи для загрузки + * @return LeadsCollection + * @throws AmoCRMApiException + */ + public function getLostLeads(int $startTimestamp, ?int $endTimestamp = null, array $with = []): LeadsCollection + { + return $this->getLeadsByStatus(LeadModel::LOST_STATUS_ID, $startTimestamp, $endTimestamp, $with); + } + + /** + * Получить сделки по ID статуса + * + * @param int $statusId ID статуса + * @param int|null $sinceTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @param array $with Связи для загрузки + * @return LeadsCollection + * @throws AmoCRMApiException + */ + public function getLeadsByStatus(int $statusId, ?int $sinceTimestamp = null, ?int $endTimestamp = null, array $with = []): LeadsCollection + { + $with = empty($with) ? $this->defaultWith : $with; + + $filter = new LeadsFilter(); + $filter->setLimit($this->defaultPageSize); + $filter->setStatuses([$statusId]); + + if ($sinceTimestamp !== null) { + $filter->setCreatedAt($sinceTimestamp, $endTimestamp); + } + + return $this->getAllLeads($filter, $with); + } + + /** + * Получить статистику по сделкам за период + * + * @param int $startTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @param array $pipelineIds Фильтр по воронкам + * @return array + * @throws AmoCRMApiException + */ + public function getLeadsStats(int $startTimestamp, ?int $endTimestamp = null, array $pipelineIds = []): array + { + $filter = new LeadsFilter(); + $filter->setCreatedAt($startTimestamp, $endTimestamp); + $filter->setLimit($this->defaultPageSize); + + if (!empty($pipelineIds)) { + $filter->setPipelineIds($pipelineIds); + } + + $totalPrice = 0; + $wonPrice = 0; + $totalCount = 0; + $wonCount = 0; + $lostCount = 0; + $byPipeline = []; + $byStatus = []; + + $collection = $this->getAllLeads($filter, [LeadModel::CONTACTS, LeadModel::COMPANY]); + + foreach ($collection as $lead) { + /** @var LeadModel $lead */ + $totalCount++; + $totalPrice += $lead->getPrice() ?? 0; + + // Группировка по воронке + $pipelineId = $lead->getPipelineId(); + if (!isset($byPipeline[$pipelineId])) { + $byPipeline[$pipelineId] = [ + 'count' => 0, + 'price' => 0, + 'won_count' => 0, + 'won_price' => 0, + 'lost_count' => 0, + ]; + } + $byPipeline[$pipelineId]['count']++; + $byPipeline[$pipelineId]['price'] += $lead->getPrice() ?? 0; + + // Группировка по статусу + $statusId = $lead->getStatusId(); + if (!isset($byStatus[$statusId])) { + $byStatus[$statusId] = [ + 'count' => 0, + 'price' => 0, + ]; + } + $byStatus[$statusId]['count']++; + $byStatus[$statusId]['price'] += $lead->getPrice() ?? 0; + + // Победы и проигрыши + if ($lead->getStatusId() === LeadModel::WON_STATUS_ID) { + $wonCount++; + $wonPrice += $lead->getPrice() ?? 0; + $byPipeline[$pipelineId]['won_count']++; + $byPipeline[$pipelineId]['won_price'] += $lead->getPrice() ?? 0; + } elseif ($lead->getStatusId() === LeadModel::LOST_STATUS_ID) { + $lostCount++; + $byPipeline[$pipelineId]['lost_count']++; + } + } + + return [ + 'total_count' => $totalCount, + 'total_price' => $totalPrice, + 'won_count' => $wonCount, + 'won_price' => $wonPrice, + 'lost_count' => $lostCount, + 'by_pipeline' => $byPipeline, + 'by_status' => $byStatus, + 'average_price' => $totalCount > 0 ? $totalPrice / $totalCount : 0, + 'win_rate' => $totalCount > 0 ? ($wonCount / $totalCount) * 100 : 0, + ]; + } + + /** + * Обработать один элемент коллекции + * + * @param LeadModel $item + * @return array|null + */ + protected function processItem($item): ?array + { + if ($item === null || !($item instanceof LeadModel)) { + return null; + } + + return \AmoCRM\Analytics\Models\LeadFactModel::fromApiModel($item)->toArray(); + } + + /** + * Применить фильтр по updatedAt к базовому фильтру + * + * @param \AmoCRM\Filters\LeadsFilter $filter + * @param int $timestamp + * @return LeadsFilter + */ + protected function applyUpdatedAtFilter($filter, int $timestamp): LeadsFilter + { + $filter->setUpdatedAt($timestamp, null); + return $filter; + } +} \ No newline at end of file diff --git a/src/AmoCRM/Analytics/Services/TransactionExportService.php b/src/AmoCRM/Analytics/Services/TransactionExportService.php new file mode 100644 index 0000000..c2bc15e --- /dev/null +++ b/src/AmoCRM/Analytics/Services/TransactionExportService.php @@ -0,0 +1,260 @@ +customersService = $apiClient->customers(); + $this->transactionsService = $apiClient->transactions(); + } + + /** + * Получить сервис сущности транзакций + * + * @return \AmoCRM\EntitiesServices\Customers\Transactions + */ + protected function getEntityService() + { + return $this->transactionsService; + } + + /** + * Получить транзакции покупателя + * + * @param int $customerId ID покупателя + * @param bool $accrueBonus Начислять ли бонусы + * @return TransactionsCollection + * @throws AmoCRMApiException + */ + public function getCustomerTransactions(int $customerId, bool $accrueBonus = false): TransactionsCollection + { + $this->transactionsService->setCustomerId($customerId); + $this->transactionsService->setAccrueBonus($accrueBonus); + + /** @var TransactionsCollection $collection */ + $collection = $this->transactionsService->get(); + + $service = $this->transactionsService; + while ($service instanceof \AmoCRM\EntitiesServices\Interfaces\HasPageMethodsInterface && $collection->count() > 0) { + $collection = $service->nextPage($collection); + } + + return $collection; + } + + /** + * Получить все транзакции всех покупателей + * + * @param int|null $sinceTimestamp Начало периода + * @param int|null $untilTimestamp Конец периода + * @return array Массив транзакций + * @throws AmoCRMApiException + */ + public function getAllTransactions(?int $sinceTimestamp = null, ?int $untilTimestamp = null): array + { + $results = []; + + // Получаем всех покупателей + $customers = $this->getAllCustomers($sinceTimestamp, $untilTimestamp); + + foreach ($customers as $customer) { + $customerId = $customer->getId(); + if ($customerId === null) { + continue; + } + + try { + $transactions = $this->getCustomerTransactions($customerId); + + foreach ($transactions as $transaction) { + /** @var TransactionModel $transaction */ + // Фильтруем по дате, если указана + $completedAt = $transaction->getCompletedAt(); + if ($sinceTimestamp !== null && $completedAt !== null && $completedAt < $sinceTimestamp) { + continue; + } + if ($untilTimestamp !== null && $completedAt !== null && $completedAt > $untilTimestamp) { + continue; + } + + $results[] = \AmoCRM\Analytics\Models\TransactionFactModel::fromApiModel($transaction)->toArray(); + } + } catch (AmoCRMApiException $e) { + $this->logError("Failed to get transactions for customer {$customerId}: " . $e->getMessage()); + } + } + + return $results; + } + + /** + * Получить всех покупателей + * + * @param int|null $sinceTimestamp Начало периода + * @param int|null $untilTimestamp Конец периода + * @return CustomersCollection + * @throws AmoCRMApiException + */ + protected function getAllCustomers(?int $sinceTimestamp = null, ?int $untilTimestamp = null): CustomersCollection + { + $filter = new \AmoCRM\Filters\CustomersFilter(); + $filter->setLimit($this->defaultPageSize); + + if ($sinceTimestamp !== null) { + $filter->setUpdatedAt($sinceTimestamp, $untilTimestamp); + } + + /** @var CustomersCollection $collection */ + $collection = $this->customersService->get($filter); + + $service = $this->customersService; + while ($service instanceof \AmoCRM\EntitiesServices\Interfaces\HasPageMethodsInterface && $collection->count() > 0) { + $collection = $service->nextPage($collection); + } + + return $collection; + } + + /** + * Получить транзакции за период + * + * @param int $startTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @return array + * @throws AmoCRMApiException + */ + public function getTransactionsBetween(int $startTimestamp, ?int $endTimestamp = null): array + { + return $this->getAllTransactions($startTimestamp, $endTimestamp); + } + + /** + * Получить статистику по транзакциям + * + * @param int $startTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @return array + * @throws AmoCRMApiException + */ + public function getTransactionStats(int $startTimestamp, ?int $endTimestamp = null): array + { + $transactions = $this->getAllTransactions($startTimestamp, $endTimestamp); + + $stats = [ + 'total_count' => 0, + 'total_price' => 0, + 'by_customer' => [], + 'average_price' => 0, + ]; + + foreach ($transactions as $transaction) { + $stats['total_count']++; + $price = $transaction['price'] ?? 0; + $stats['total_price'] += $price; + + $customerId = $transaction['customer_id'] ?? null; + if ($customerId !== null) { + if (!isset($stats['by_customer'][$customerId])) { + $stats['by_customer'][$customerId] = [ + 'count' => 0, + 'total_price' => 0, + ]; + } + $stats['by_customer'][$customerId]['count']++; + $stats['by_customer'][$customerId]['total_price'] += $price; + } + } + + $stats['average_price'] = $stats['total_count'] > 0 + ? $stats['total_price'] / $stats['total_count'] + : 0; + + return $stats; + } + + /** + * Получить топ покупателей по сумме покупок + * + * @param int $startTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @param int $limit Количество покупателей + * @return array + * @throws AmoCRMApiException + */ + public function getTopCustomers(int $startTimestamp, ?int $endTimestamp = null, int $limit = 10): array + { + $stats = $this->getTransactionStats($startTimestamp, $endTimestamp); + + $customers = $stats['by_customer']; + uasort($customers, function ($a, $b) { + return $b['total_price'] - $a['total_price']; + }); + + return array_slice($customers, 0, $limit, true); + } + + /** + * Применить фильтр по createdAt к базовому фильтру + * + * @param \AmoCRM\Filters\CustomersFilter $filter + * @param int $timestamp + * @return \AmoCRM\Filters\CustomersFilter + */ + protected function applyUpdatedAtFilter($filter, int $timestamp): \AmoCRM\Filters\CustomersFilter + { + $filter->setUpdatedAt($timestamp, null); + return $filter; + } + + /** + * Обработать один элемент коллекции + * + * @param TransactionModel $item + * @return array|null + */ + protected function processItem($item): ?array + { + if ($item === null || !($item instanceof TransactionModel)) { + return null; + } + + return \AmoCRM\Analytics\Models\TransactionFactModel::fromApiModel($item)->toArray(); + } +} \ No newline at end of file diff --git a/src/AmoCRM/Analytics/Services/UnsortedExportService.php b/src/AmoCRM/Analytics/Services/UnsortedExportService.php new file mode 100644 index 0000000..05d1c0f --- /dev/null +++ b/src/AmoCRM/Analytics/Services/UnsortedExportService.php @@ -0,0 +1,283 @@ +apiClient->unsorted(); + } + + /** + * Создать фильтр неразобранного + * + * @param int|null $sinceTimestamp Начало периода + * @param int|null $untilTimestamp Конец периода + * @param array $categories Категории для фильтрации + * @param int|null $pipelineId ID воронки + * @return UnsortedFilter + */ + public function createFilter( + ?int $sinceTimestamp = null, + ?int $untilTimestamp = null, + array $categories = [], + ?int $pipelineId = null + ): UnsortedFilter { + $filter = new UnsortedFilter(); + + if ($sinceTimestamp !== null) { + $filter->setCreatedAt($sinceTimestamp, $untilTimestamp); + } + + if (!empty($categories)) { + $filter->setCategory($categories); + } + + if ($pipelineId !== null) { + $filter->setPipelineId($pipelineId); + } + + return $filter; + } + + /** + * Получить все обращения неразобранного + * + * @param UnsortedFilter|null $filter + * @return UnsortedCollection + * @throws AmoCRMApiException + */ + public function getAllUnsorted(?UnsortedFilter $filter = null): UnsortedCollection + { + if ($filter === null) { + $filter = new UnsortedFilter(); + } + + /** @var UnsortedCollection $collection */ + $collection = $this->getEntityService()->get($filter); + + $service = $this->getEntityService(); + while ($service instanceof \AmoCRM\EntitiesServices\Interfaces\HasPageMethodsInterface && $collection->count() > 0) { + $collection = $service->nextPage($collection); + } + + return $collection; + } + + /** + * Получить обращения за период + * + * @param int $startTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @param array $categories Категории + * @return array + * @throws AmoCRMApiException + */ + public function getUnsortedBetween(int $startTimestamp, ?int $endTimestamp = null, array $categories = []): array + { + $filter = $this->createFilter($startTimestamp, $endTimestamp, $categories); + + $collection = $this->getAllUnsorted($filter); + $results = []; + + foreach ($collection as $item) { + /** @var BaseUnsortedModel $item */ + $results[] = \AmoCRM\Analytics\Models\FirstTouchFactModel::fromApiModel($item)->toArray(); + } + + return $results; + } + + /** + * Получить сводку по неразобранному + * + * @param int|null $sinceTimestamp Начало периода + * @param int|null $untilTimestamp Конец периода + * @return array + * @throws AmoCRMApiException + */ + public function getSummary(?int $sinceTimestamp = null, ?int $untilTimestamp = null): array + { + $filter = new UnsortedFilter(); + + if ($sinceTimestamp !== null) { + $filter->setCreatedAt($sinceTimestamp, $untilTimestamp); + } + + return $this->getEntityService()->summary($filter)->toArray(); + } + + /** + * Получить статистику по категориям + * + * @param int $startTimestamp Начало периода + * @param int|null $endTimestamp Конец периода + * @return array + * @throws AmoCRMApiException + */ + public function getCategoryStats(int $startTimestamp, ?int $endTimestamp = null): array + { + $filter = $this->createFilter($startTimestamp, $endTimestamp); + $collection = $this->getAllUnsorted($filter); + + $stats = [ + 'total' => 0, + 'by_category' => [], + 'by_pipeline' => [], + ]; + + foreach ($collection as $item) { + /** @var BaseUnsortedModel $item */ + $stats['total']++; + + $category = $item->getCategory(); + if (!isset($stats['by_category'][$category])) { + $stats['by_category'][$category] = 0; + } + $stats['by_category'][$category]++; + + $pipelineId = $item->getPipelineId(); + if (!isset($stats['by_pipeline'][$pipelineId])) { + $stats['by_pipeline'][$pipelineId] = 0; + } + $stats['by_pipeline'][$pipelineId]++; + } + + return $stats; + } + + /** + * Получить "горячие" обращения (SIP и чаты) + * + * @param int|null $sinceTimestamp Начало периода + * @param int|null $untilTimestamp Конец периода + * @return array + * @throws AmoCRMApiException + */ + public function getHotLeads(?int $sinceTimestamp = null, ?int $untilTimestamp = null): array + { + $filter = $this->createFilter( + $sinceTimestamp, + $untilTimestamp, + [self::CATEGORY_SIP, self::CATEGORY_CHATS] + ); + + $collection = $this->getAllUnsorted($filter); + $results = []; + + foreach ($collection as $item) { + /** @var BaseUnsortedModel $item */ + $results[] = \AmoCRM\Analytics\Models\FirstTouchFactModel::fromApiModel($item)->toArray(); + } + + return $results; + } + + /** + * Получить обращения из форм + * + * @param int|null $sinceTimestamp Начало периода + * @param int|null $untilTimestamp Конец периода + * @return array + * @throws AmoCRMApiException + */ + public function getFormSubmissions(?int $sinceTimestamp = null, ?int $untilTimestamp = null): array + { + $filter = $this->createFilter( + $sinceTimestamp, + $untilTimestamp, + [self::CATEGORY_FORMS] + ); + + $collection = $this->getAllUnsorted($filter); + $results = []; + + foreach ($collection as $item) { + /** @var BaseUnsortedModel $item */ + $results[] = \AmoCRM\Analytics\Models\FirstTouchFactModel::fromApiModel($item)->toArray(); + } + + return $results; + } + + /** + * Применить фильтр по createdAt к базовому фильтру + * + * @param UnsortedFilter $filter + * @param int $timestamp + * @return UnsortedFilter + */ + protected function applyUpdatedAtFilter($filter, int $timestamp): UnsortedFilter + { + $filter->setCreatedAt($timestamp, null); + return $filter; + } + + /** + * Обработать один элемент коллекции + * + * @param BaseUnsortedModel $item + * @return array|null + */ + protected function processItem($item): ?array + { + if ($item === null || !($item instanceof BaseUnsortedModel)) { + return null; + } + + return \AmoCRM\Analytics\Models\FirstTouchFactModel::fromApiModel($item)->toArray(); + } +} \ No newline at end of file diff --git a/src/AmoCRM/Analytics/Services/WebhookHandler.php b/src/AmoCRM/Analytics/Services/WebhookHandler.php new file mode 100644 index 0000000..08220ea --- /dev/null +++ b/src/AmoCRM/Analytics/Services/WebhookHandler.php @@ -0,0 +1,333 @@ +apiClient = $apiClient; + $this->registerDefaultHandlers(); + } + + /** + * Зарегистрировать обработчики по умолчанию + */ + protected function registerDefaultHandlers(): void + { + $this->eventHandlers[self::EVENT_ADD_LEAD] = [$this, 'handleLeadEvent']; + $this->eventHandlers[self::EVENT_UPDATE_LEAD] = [$this, 'handleLeadEvent']; + $this->eventHandlers[self::EVENT_DELETE_LEAD] = [$this, 'handleLeadEvent']; + $this->eventHandlers[self::EVENT_ADD_UNSORTED] = [$this, 'handleUnsortedEvent']; + $this->eventHandlers[self::EVENT_UPDATE_UNSORTED] = [$this, 'handleUnsortedEvent']; + $this->eventHandlers[self::EVENT_ADD_TRANSACTION] = [$this, 'handleTransactionEvent']; + $this->eventHandlers[self::EVENT_UPDATE_TRANSACTION] = [$this, 'handleTransactionEvent']; + $this->eventHandlers[self::EVENT_DELETE_TRANSACTION] = [$this, 'handleTransactionEvent']; + $this->eventHandlers[self::EVENT_ADD_CALL] = [$this, 'handleCallEvent']; + $this->eventHandlers[self::EVENT_UPDATE_CALL] = [$this, 'handleCallEvent']; + } + + /** + * Обработать входящий webhook запрос + * + * @param array $payload Данные из POST запроса + * @param string $eventType Тип события (из headers или payload) + * @return array|null Обработанные данные или null + */ + public function handle(array $payload, string $eventType): ?array + { + $result = null; + + if (isset($this->eventHandlers[$eventType])) { + $handler = $this->eventHandlers[$eventType]; + $result = call_user_func($handler, $payload); + } + + // Логируем обработку + $this->processedLog[] = [ + 'timestamp' => time(), + 'event_type' => $eventType, + 'result' => $result !== null, + 'payload_keys' => array_keys($payload), + ]; + + // Вызываем callback для сохранения + if ($result !== null && $this->saveCallback !== null) { + call_user_func($this->saveCallback, $eventType, $result); + } + + return $result; + } + + /** + * Обработать webhook из POST запроса (сырые данные) + * + * @param string $rawBody Тело запроса + * @param array $headers Заголовки запроса + * @return array|null + */ + public function handleFromRequest(string $rawBody, array $headers = []): ?array + { + $payload = json_decode($rawBody, true); + + if ($payload === null) { + return null; + } + + // Определяем тип события из заголовков или payload + $eventType = $headers['X-Amojo-Signature'] ?? $payload['type'] ?? 'unknown'; + + return $this->handle($payload, $eventType); + } + + /** + * Зарегистрировать обработчик для события + * + * @param string $eventType Тип события + * @param callable $handler Обработчик + * @return self + */ + public function registerHandler(string $eventType, callable $handler): self + { + $this->eventHandlers[$eventType] = $handler; + return $this; + } + + /** + * Установить callback для сохранения данных + * + * @param callable $callback function(string $eventType, array $data) + * @return self + */ + public function setSaveCallback(callable $callback): self + { + $this->saveCallback = $callback; + return $this; + } + + /** + * Обработать событие сделки + * + * @param array $payload + * @return array|null + */ + protected function handleLeadEvent(array $payload): ?array + { + $leads = $payload['leads'] ?? []; + if (empty($leads) || !isset($leads['add'][$_ = 0]) && !isset($leads['update'][$_ = 0]) && !isset($leads['delete'][$_ = 0])) { + return null; + } + + // Обработка изменений статуса + if (isset($leads['status'])) { + $statusChange = $leads['status']; + $historyModel = LeadStatusHistoryModel::fromWebhook([ + 'leads' => $statusChange, + 'account_id' => $payload['account_id'] ?? null, + ]); + + return [ + 'type' => 'status_history', + 'data' => $historyModel->toArray(), + ]; + } + + // Обработка добавления/обновления/удаления сделки + $items = $leads['add'] ?? $leads['update'] ?? $leads['delete'] ?? []; + $results = []; + + foreach ($items as $leadData) { + $factModel = LeadFactModel::fromArray([ + 'lead_id' => $leadData['id'] ?? null, + 'name' => $leadData['name'] ?? null, + 'pipeline_id' => $leadData['pipeline_id'] ?? null, + 'status_id' => $leadData['status_id'] ?? null, + 'price' => $leadData['price'] ?? null, + 'responsible_user_id' => $leadData['responsible_user_id'] ?? null, + 'is_deleted' => isset($leads['delete']), + ]); + + $results[] = $factModel->toArray(); + } + + return [ + 'type' => 'lead', + 'data' => $results, + ]; + } + + /** + * Обработать событие неразобранного + * + * @param array $payload + * @return array|null + */ + protected function handleUnsortedEvent(array $payload): ?array + { + $factModel = FirstTouchFactModel::fromWebhook($payload); + + return [ + 'type' => 'first_touch', + 'data' => $factModel->toArray(), + ]; + } + + /** + * Обработать событие транзакции + * + * @param array $payload + * @return array|null + */ + protected function handleTransactionEvent(array $payload): ?array + { + $factModel = TransactionFactModel::fromWebhook($payload); + + return [ + 'type' => 'transaction', + 'data' => $factModel->toArray(), + ]; + } + + /** + * Обработать событие звонка + * + * @param array $payload + * @return array|null + */ + protected function handleCallEvent(array $payload): ?array + { + $factModel = CallFactModel::fromWebhook($payload); + + return [ + 'type' => 'call', + 'data' => $factModel->toArray(), + ]; + } + + /** + * Получить лог обработки + * + * @return array + */ + public function getProcessedLog(): array + { + return $this->processedLog; + } + + /** + * Очистить лог + * + * @return self + */ + public function clearLog(): self + { + $this->processedLog = []; + return $this; + } + + /** + * Валидировать webhook payload + * + * @param array $payload + * @return bool + */ + public static function validatePayload(array $payload): bool + { + // Проверяем наличие обязательных полей + return isset($payload['account_id']) && isset($payload['contact']); + } + + /** + * Получить список поддерживаемых событий + * + * @return array + */ + public static function getSupportedEvents(): array + { + return self::ANALYTICS_EVENTS; + } +} \ No newline at end of file diff --git a/src/AmoCRM/Analytics/Services/WebhookSubscriptionService.php b/src/AmoCRM/Analytics/Services/WebhookSubscriptionService.php new file mode 100644 index 0000000..1a0d6b0 --- /dev/null +++ b/src/AmoCRM/Analytics/Services/WebhookSubscriptionService.php @@ -0,0 +1,357 @@ +apiClient = $apiClient; + $this->webhookUrl = $webhookUrl; + } + + /** + * Установить URL для вебхуков + * + * @param string $url + * @return self + */ + public function setWebhookUrl(string $url): self + { + $this->webhookUrl = $url; + return $this; + } + + /** + * Подписаться на все события аналитики + * + * @param string|null $destination URL для получения вебхуков + * @param array|null $events Список событий (null = все аналитикс события) + * @return WebhookModel|null + * @throws AmoCRMApiException + */ + public function subscribe(?string $destination = null, ?array $events = null): ?WebhookModel + { + $destination = $destination ?? $this->webhookUrl; + + if ($destination === null) { + throw new \InvalidArgumentException('Webhook destination URL is required'); + } + + $events = $events ?? self::ANALYTICS_EVENTS; + + $webhook = new WebhookModel(); + $webhook->setDestination($destination) + ->setSettings($events); + + return $this->apiClient->webhooks()->subscribe($webhook); + } + + /** + * Подписаться на основные события аналитики + * + * @param string|null $destination URL для получения вебхуков + * @return WebhookModel|null + * @throws AmoCRMApiException + */ + public function subscribeMain(?string $destination = null): ?WebhookModel + { + return $this->subscribe($destination, self::MAIN_ANALYTICS_EVENTS); + } + + /** + * Отписаться от вебхуков + * + * @param WebhookModel $webhook Модель вебхука для отписки + * @return bool + * @throws AmoCRMApiException + */ + public function unsubscribe(WebhookModel $webhook): bool + { + return $this->apiClient->webhooks()->unsubscribe($webhook); + } + + /** + * Отписаться от всех вебхуков аналитики + * + * @param string|null $destination URL вебхука для отписки + * @return int Количество удалённых подписок + * @throws AmoCRMApiException + */ + public function unsubscribeAll(?string $destination = null): int + { + $deletedCount = 0; + $webhooks = $this->getActiveWebhooks(); + + foreach ($webhooks as $webhook) { + /** @var WebhookModel $webhook */ + $webhookDestination = $webhook->getDestination(); + + // Если указан destination, удаляем только совпадающие + if ($destination !== null && $webhookDestination !== $destination) { + continue; + } + + // Проверяем, содержит ли вебхук события аналитики + $settings = $webhook->getSettings() ?? []; + $hasAnalyticsEvents = array_intersect($settings, self::ANALYTICS_EVENTS); + + if (!empty($hasAnalyticsEvents)) { + if ($this->unsubscribe($webhook)) { + $deletedCount++; + } + } + } + + return $deletedCount; + } + + /** + * Получить все активные подписки + * + * @param WebhooksFilter|null $filter + * @return WebhooksCollection + * @throws AmoCRMApiException + */ + public function getActiveWebhooks(?WebhooksFilter $filter = null): WebhooksCollection + { + if ($filter === null) { + $filter = new WebhooksFilter(); + } + + return $this->apiClient->webhooks()->get($filter); + } + + /** + * Проверить, активна ли подписка для URL + * + * @param string $destination URL для проверки + * @return bool + * @throws AmoCRMApiException + */ + public function isSubscribed(string $destination): bool + { + $filter = new WebhooksFilter(); + $filter->setDestination($destination); + + $webhooks = $this->getActiveWebhooks($filter); + + return !$webhooks->isEmpty(); + } + + /** + * Получить информацию о текущей подписке аналитики + * + * @return array|null + * @throws AmoCRMApiException + */ + public function getCurrentSubscription(): ?array + { + if ($this->webhookUrl === null) { + return null; + } + + $filter = new WebhooksFilter(); + $filter->setDestination($this->webhookUrl); + + $webhooks = $this->getActiveWebhooks($filter); + + if ($webhooks->isEmpty()) { + return null; + } + + /** @var WebhookModel $webhook */ + $webhook = $webhooks->first(); + + return [ + 'destination' => $webhook->getDestination(), + 'settings' => $webhook->getSettings(), + 'id' => method_exists($webhook, 'getId') ? $webhook->getId() : null, + ]; + } + + /** + * Обновить подписку на новые события + * + * @param array $newEvents Новый список событий + * @param string|null $destination URL вебхука + * @return WebhookModel|null + * @throws AmoCRMApiException + */ + public function updateSubscription(array $newEvents, ?string $destination = null): ?WebhookModel + { + $destination = $destination ?? $this->webhookUrl; + + if ($destination === null) { + throw new \InvalidArgumentException('Webhook destination URL is required'); + } + + // Отписываемся от старой подписки + $this->unsubscribeAll($destination); + + // Подписываемся на новые события + return $this->subscribe($destination, $newEvents); + } + + /** + * Добавить события к текущей подписке + * + * @param array $additionalEvents Дополнительные события + * @param string|null $destination URL вебхука + * @return WebhookModel|null + * @throws AmoCRMApiException + */ + public function addEvents(array $additionalEvents, ?string $destination = null): ?WebhookModel + { + $current = $this->getCurrentSubscription(); + + $currentEvents = $current['settings'] ?? []; + $newEvents = array_unique(array_merge($currentEvents, $additionalEvents)); + + return $this->updateSubscription($newEvents, $destination); + } + + /** + * Удалить события из текущей подписки + * + * @param array $eventsToRemove События для удаления + * @param string|null $destination URL вебхука + * @return WebhookModel|null + * @throws AmoCRMApiException + */ + public function removeEvents(array $eventsToRemove, ?string $destination = null): ?WebhookModel + { + $current = $this->getCurrentSubscription(); + + $currentEvents = $current['settings'] ?? []; + $newEvents = array_values(array_diff($currentEvents, $eventsToRemove)); + + if (empty($newEvents)) { + // Удаляем подписку полностью + $this->unsubscribeAll($destination ?? $this->webhookUrl); + return null; + } + + return $this->updateSubscription($newEvents, $destination); + } + + /** + * Получить статистику подписок + * + * @return array + * @throws AmoCRMApiException + */ + public function getSubscriptionStats(): array + { + $webhooks = $this->getActiveWebhooks(); + + $stats = [ + 'total_webhooks' => 0, + 'analytics_webhooks' => 0, + 'by_events' => [], + ]; + + foreach ($webhooks as $webhook) { + /** @var WebhookModel $webhook */ + $stats['total_webhooks']++; + + $settings = $webhook->getSettings() ?? []; + $hasAnalytics = false; + + foreach ($settings as $event) { + if (in_array($event, self::ANALYTICS_EVENTS, true)) { + $hasAnalytics = true; + if (!isset($stats['by_events'][$event])) { + $stats['by_events'][$event] = 0; + } + $stats['by_events'][$event]++; + } + } + + if ($hasAnalytics) { + $stats['analytics_webhooks']++; + } + } + + return $stats; + } +} \ No newline at end of file diff --git a/src/AmoCRM/Client/AmoCRMClientFactory.php b/src/AmoCRM/Client/AmoCRMClientFactory.php new file mode 100644 index 0000000..4be2155 --- /dev/null +++ b/src/AmoCRM/Client/AmoCRMClientFactory.php @@ -0,0 +1,193 @@ +make(); + } + + return $apiClient; + } + + /** + * Создать API-клиент с долгоживущим токеном + * + * @param string $accessToken Долгоживущий токен доступа + * @param string $accountDomain Домен аккаунта (например, 'subdomain.amocrm.ru') + * @return AmoCRMApiClient + */ + public static function createWithLongLivedToken( + string $accessToken, + string $accountDomain + ): AmoCRMApiClient { + $apiClient = new AmoCRMApiClient(); + $longLivedToken = new LongLivedAccessToken($accessToken); + + $apiClient + ->setAccessToken($longLivedToken) + ->setAccountBaseDomain($accountDomain); + + return $apiClient; + } + + /** + * Создать API-клиент с AccessToken объектом + * + * @param AccessTokenInterface $accessToken Объект токена + * @param string|null $accountDomain Домен аккаунта (берётся из токена если не указан) + * @param callable|null $onTokenRefresh Callback для обновления токена + * @return AmoCRMApiClient + */ + public static function createWithAccessToken( + AccessTokenInterface $accessToken, + ?string $accountDomain = null, + ?callable $onTokenRefresh = null + ): AmoCRMApiClient { + $apiClient = new AmoCRMApiClient(); + + $apiClient->setAccessToken($accessToken); + + // Если домен передан явно или есть в токене + $domain = $accountDomain ?? $accessToken->getValues()['baseDomain'] ?? null; + if ($domain !== null) { + $apiClient->setAccountBaseDomain($domain); + } + + // Устанавливаем callback для обновления токена + if ($onTokenRefresh !== null) { + $apiClient->onAccessTokenRefresh($onTokenRefresh); + } + + return $apiClient; + } + + /** + * Восстановить API-клиент из сохранённого состояния токена + * + * @param array $tokenData Массив с данными токена + * ['accessToken', 'refreshToken', 'expires', 'baseDomain'] + * @param callable|null $onTokenRefresh Callback для обновления токена + * @return AmoCRMApiClient + */ + public static function restoreFromTokenData( + array $tokenData, + ?callable $onTokenRefresh = null + ): AmoCRMApiClient { + $accessToken = new AccessToken([ + 'access_token' => $tokenData['accessToken'] ?? '', + 'refresh_token' => $tokenData['refreshToken'] ?? '', + 'expires' => $tokenData['expires'] ?? time(), + 'baseDomain' => $tokenData['baseDomain'] ?? '', + ]); + + return self::createWithAccessToken( + $accessToken, + $tokenData['baseDomain'] ?? null, + $onTokenRefresh + ); + } + + /** + * Получить URL для авторизации OAuth + * + * @param AmoCRMApiClient $apiClient + * @param string $state CSRF токен для безопасности + * @param string $mode Режим авторизации ('post_message' или 'popup') + * @return string URL авторизации + */ + public static function getAuthorizationUrl( + AmoCRMApiClient $apiClient, + string $state = '', + string $mode = 'post_message' + ): string { + return $apiClient->getOAuthClient()->getAuthorizeUrl([ + 'state' => $state, + 'mode' => $mode, + ]); + } + + /** + * Получить AccessToken по коду авторизации + * + * @param AmoCRMApiClient $apiClient + * @param string $code Код авторизации из OAuth callback + * @return AccessTokenInterface + */ + public static function getAccessTokenByCode( + AmoCRMApiClient $apiClient, + string $code + ): AccessTokenInterface { + return $apiClient->getOAuthClient()->getAccessTokenByCode($code); + } + + /** + * Создать API-клиент с контекстом конкретного пользователя (для admin-токенов) + * + * @param AmoCRMApiClient $apiClient Базовый API-клиент + * @param int $userId ID пользователя для контекста + * @return AmoCRMApiClient API-клиент с установленным контекстом + */ + public static function withUserContext(AmoCRMApiClient $apiClient, int $userId): AmoCRMApiClient + { + return $apiClient->withContextUserId($userId); + } + + /** + * Создать API-клиент с кастомным User Agent + * + * @param AmoCRMApiClient $apiClient Базовый API-клиент + * @param string $userAgent Название приложения/интеграции + * @return AmoCRMApiClient API-клиент с установленным User Agent + */ + public static function withUserAgent(AmoCRMApiClient $apiClient, string $userAgent): AmoCRMApiClient + { + return $apiClient->setUserAgnet($userAgent); + } + + /** + * Создать API-клиент с кастомным обработчиком HTTP-статусов + * + * @param AmoCRMApiClient $apiClient Базовый API-клиент + * @param callable $callback Функция обратного вызова для обработки ответов + * @return AmoCRMApiClient + */ + public static function withHttpStatusCallback(AmoCRMApiClient $apiClient, callable $callback): AmoCRMApiClient + { + return $apiClient->setCheckHttpStatusCallback($callback); + } +} \ No newline at end of file