# Поддержка дайлера Infinity в модуле компаний — дизайн-спека

**Дата:** 2026-07-20
**Статус:** согласовано, вопросы закрыты (см. раздел 5); можно приступать к реализации
**Цель:** запускать обзвон списков из модуля компаний не только через Webitel (Перу), но и через Infinity/Era (Казахстан), не ломая работающий перуанский путь.

---

## 1. Как это работает сейчас

Цепочка запуска телефонии из компаний:

```
CompanySchedulerCommand (cron, LEAD_TIME['ALL'], everyMinute)
  └─ CommunicationCompanyService::createLaunch()      → CompanyLaunch + company_launch_lists
  └─ ExecuteCompanyLaunchJob (очередь companies)
       └─ CommunicationCompanyService::executeLaunch()
            └─ TelephonyCommunicationStrategy            (CompanyAction::COMPANY_TELEPHONY)
                 ├─ prepareTelephonyTemplates()          → telephony.templates
                 └─ TelephonyLaunchService::execute()    → telephony.buckets + TelephonyBucketLauncherJob
                      └─ AbstractTelephonyProvider::execute() на каждый номер → telephony.histories
```

Выбор очереди в UI: `ScheduleComponent::getQueuesForTelephonyAction()` тянет очереди из Webitel в момент рендера
и сохраняет `queue_for_telephony` + `queue_for_telephony_name` в `company_schedules.additional_information`.

Провайдер резолвится в `AppServiceProvider.php:147` через `match(config('polaris.instance'))`, где определён
только `INSTANCE_PERU => WebitelTelephonyProvider`, остальные инстансы кидают исключение.

---

## 2. Что известно про API Infinity

Проверено вживую на `https://era.smsfinanceit.ru` (токен из `config/integrations.infinity.token`).
Авторизация: `Authorization: Bearer <token>` — отличается от Webitel (`X-Webitel-Access`).

### 2.1 Кампании — аналог очередей Webitel

`GET /rest/v1/model/callcenter/outbound/Campaigns` → плоский JSON-массив (не `{items: []}`, как у Webitel),
36 объектов по 72 поля. Значимые поля:

| Поле | Пример | Комментарий |
|---|---|---|
| `id` | `d6488fc1-4404-4e07-9312-2a3f2559e526` | **UUID-строка**, не число |
| `name` | `[КЦ] Заявки на звонок` | отображаемое имя |
| `kind` | `predictive` / `progressive` / `manual` / `autoinformator` / `demo` | тип обзвона |
| `state` | `running` / `stoped` / `error` | текущее состояние |
| `stateDescription` | `Очередь распределения вызовов не задана 10.06.2026` | причина ошибки |
| `service_code` | `cc_service` / `ddv_srv` / `TEST_CC_SERVICE` | группировка, аналог `team_id=2` у Webitel |
| `maxTryCount`, `repeatRules`, `timeRangeRules` | | дайлер сам ретраит и соблюдает окна дозвона |

Из 36 кампаний часть — мусорные/тестовые (`Oleg Test`, `123`, `Демо2`) и в состоянии `error`.
Показывать в выпадашке весь список нельзя — нужен фильтр (см. вопрос Q2).

`kind` кампании почти совпадает с нашими `company_schedule_subtypes` для action_id=4
(`ivr`, `voicebot`, `predictive`, `progressive`), то есть у Infinity тип обзвона задаётся **самой кампанией**,
а не нашим подтипом расписания. Это меняет смысл поля subtype для казахстанского инстанса (вопрос Q6).

### 2.2 Добавление контакта — аналог `queues/{id}/members`

`POST /rest/v1/model/callcenter/outbound/SimpleContragents`

```json
{
  "campaign_id": "5a100e1f-7988-493f-98a1-7f4a026f8aec",
  "fio": "Тестов Тест Тестович",
  "phoneNumbers": [{"kind": "mobile", "phoneNumber": "79001234567"}],
  "notes": "<ссылка на карточку клиента>"
}
```

Структура сущности (проверено через `GET .../SimpleContragents/{id}`):

```json
{
  "campaign_id": "...", "id": "38aea5db-...", "fio": "...", "notes": "...",
  "phoneNumbers": [{"kind": "mobile", "phoneNumber": "89987923993",
                    "state": "processed", "tryCount": 2, "lastTryTime": "2026-03-31T05:49:16.744Z"}],
  "ext": {}, "state": "processed", "tryCount": 3, "result_code": null,
  "scheduledTime": null, "scheduledNumber": null, "searchContext": "89987923993", "increment": 15
}
```

Ответ на POST (подтверждён заказчиком) — эхо созданного объекта:

```json
{
  "campaign_id": "5a100e1f-7988-493f-98a1-7f4a026f8aec",
  "fio": "Тестов Тест Тестович",
  "id": "d293fea4-ea8e-405b-b3fb-30c7843b9465",
  "increment": 3250,
  "notes": "<ссылка на карточку клиента>",
  "phoneNumbers": [{"kind": "mobile", "phoneNumber": "79001234567"}]
}
```

Признак успеха — наличие `id`, что совпадает с текущей логикой `processResponse()`.

Три важных следствия:

1. **`id` контакта — UUID.** Он пишется в `telephony.histories.communication_id`; тип колонки уже
   приведён к строке (см. 3.2.1). `increment` (3250) — порядковый номер контакта в кампании,
   может пригодиться для сверки объёма загрузки.
2. **`ext` — произвольный объект-расширение**, аналог `variables` у Webitel. **Не используем** — см. Q3:
   наши идентификаторы дайлеру передавать незачем.
3. **`state` / `tryCount` / `result_code` доступны по GET** — есть путь к обратной связи без вебхуков.

**Фильтрация в GET-коллекции не работает.** Проверено: `?campaign_id=<uuid>` и `?searchContext=<номер>`
игнорируются — оба возвращают одни и те же 2483 записи по 23 кампаниям. Работает только `?limit=N`.
Значит проверить «этот контакт уже в кампании» текущим способом нельзя (вопрос Q4).

### 2.3 Что по Infinity уже написано в проекте

- `InfinityCallService` — click-to-call из карточки клиента (`POST /rest/v1/uc/calls`), пишет `InfinityCallLog`.
- `InfinityWebhookService` + `SubscribeInfinityWebhooksCommand` (в расписании KZ на 00:30) — подписка на
  события `callevents.route_fin`, `callevents.dlg_stop`.
- `InfinityCallbackController` → `InfinityWebhookEventJob` — события **сохраняются** в
  `infinity_webhook_events`, но `handleRouteFin()` и `handleDlgStop()` — пустые заглушки.
- `InfinityCommunicationMigrator` (INSTANCE_KAZAKHSTAN) — переносит несостоявшиеся звонки из БД
  `infinity_dialer`, документ резолвится **по номеру телефона** через `14_telefonos`.

То есть обратная связь по звонкам для KZ уже частично закрыта миграцией по номеру — в отличие от Webitel,
где матчинг идёт по `variables.DocumentID`. Отдельный вебхук-путь для компаний, скорее всего, не нужен.

---

## 3. Целевая архитектура

Принцип: `TelephonyCommunicationStrategy`, `TelephonyLaunchService`, `Bucket`/`Template`/`History` остаются
общими; всё, что специфично для дайлера, уезжает за два интерфейса — «список целевых контейнеров»
(очередь/кампания) и «провайдер отправки».

### 3.1 Абстракция справочника очередей/кампаний

Новый контракт, например `App\Services\CommunicationIntegrations\Telephony\Contracts\DialerQueueDirectoryContract`:

```php
public function queuesForCompanies(): Collection; // Collection<DialerQueueDTO>
```

`DialerQueueDTO { string $id; string $name; ?string $kind; }` — **`id` строкой**, потому что у Webitel
это int, а у Infinity UUID. Реализации: `WebitelQueueDirectory` (обёртка над существующим
`WebitelQueueService::getAllQueues()`) и `InfinityCampaignDirectory` (`GET Campaigns`).

`InfinityCampaignDirectory` отфильтровывает кампании в состоянии `error` (решение по Q2) — это отсекает
как сломанные конфигурации («Очередь распределения вызовов не задана»), так и часть тестового мусора.
`running` и `stoped` показываем: `stoped` — нормальное состояние кампании, ждущей загрузки списка.

### 3.2 Чистка протечек Webitel в общем коде

Без этого второй провайдер не подключается (детали — в разборе от 2026-07-20):

| Файл | Проблема | Решение |
|---|---|---|
| `TelephonyBucketLauncherJob.php:70` | `match(true) { $provider instanceof WebitelTelephonyProvider => ... }` без `default` → `UnhandledMatchError` на любом новом провайдере | решение «по одному / батчем» переносится на провайдер (`supportsBatch(): bool`) |
| `TelephonyBucketLauncherJob::formatPhoneNumber()` | приклеивает `51` в общем коде | нормализация номера — в провайдер: Webitel сохраняет текущее поведение, Infinity отдаёт номер **как есть** (решение по Q10) |
| `TelephonyProviderContract::execute($dto, string $queueId)` | «очередь» протекла в сигнатуры всей цепочки | `queueId` уходит внутрь `SendTelephonyDTO` как `providerOptions`/`targetId` (строкой) |
| `AbstractTelephonyProvider::processResponse()` | ждёт `$responseData['id']` — формат Webitel в базовом классе | разбор ответа — в провайдер; в базе оставить абстрактный метод |
| `AbstractTelephonyProvider::request(): GuzzleHttp\Psr7\Response` | жёсткая привязка к Guzzle, а Infinity-код в проекте написан на `Http`-фасаде | тип → `Psr\Http\Message\ResponseInterface` |
| `WebitelTelephonyProvider::processResponse/processBatchResponse` | дословные копии родительских | удалить |
| `AppServiceProvider.php:147` | в `match` по инстансу есть только `INSTANCE_PERU` | дописать `INSTANCE_KAZAKHSTAN => InfinityTelephonyProvider::class` — этого достаточно (см. ниже) |

**Выбор провайдера остаётся привязкой к инстансу.** Раз телефония компаний включается только на KZ
(решение по Q7), настройка `ApplicationSettings::$telephony_provider` и колонка `provider`
в `telephony.buckets` не нужны: в каждом инстансе ровно один дайлер, и по инстансу однозначно понятно,
какой. `TelephonyProviderFactory` так и остаётся неиспользуемой — трогать её в рамках этой задачи не нужно.
Если когда-нибудь появится второй дайлер в одной стране, вернёмся к настройке.

### 3.2.1 Тип `histories.communication_id` — СДЕЛАНО

`telephony.histories.communication_id` — колонка нашей таблицы (создана миграцией `2026_07_08_123915`
специально под этот модуль). В неё пишется внешний идентификатор записи в списке обзвона на стороне
дайлера: member у Webitel, contragent у Infinity. Никакой gestión на этом этапе не создаётся — коммуникация
появляется позже, по факту состоявшегося звонка, и её заводит мигратор.

Колонка была объявлена как `bigInteger`, чего хватало для числовых id Webitel, но UUID Infinity в `bigint`
не пишется: Postgres отвечает `invalid input syntax for type bigint`, исключение перехватывает общий `catch`
в `AbstractTelephonyProvider::execute()`, и запись уходит в `STATUS_FAILED` — при том что контакт в кампании
реально создан.

Исправлено миграцией `2026_07_20_125524_change_communication_id_type_in_telephony_histories_table`:
`ALTER COLUMN communication_id TYPE VARCHAR(255) USING communication_id::VARCHAR`. Существующие числовые
значения Webitel конвертируются без потерь. Откат приводит колонку обратно к `bigint`, сохраняя только
чисто числовые значения (не представимые в `bigint` UUID становятся `NULL`) — проверено на тестовых
данных в обе стороны.

### 3.3 `InfinityTelephonyProvider`

Реализует `buildPayload()` — ровно четыре поля: `campaign_id`, `fio`, `phoneNumbers[{kind: mobile,
phoneNumber}]`, `notes` (ссылка на карточку клиента для оператора); `ext` не заполняем (Q3).
`request()` — через `Http::withToken(config('integrations.infinity.token'))`, как остальной Infinity-код
в проекте. Из ответа берём `id` в `histories.communication_id`.
Регистрируется в `TelephonyProviderFactory::$providers` как `'infinity'`.

### 3.4 UI расписания

- `ScheduleComponent::$queueForTelephony` сейчас `public ?int` с правилом `['required','integer']` —
  **UUID Infinity в это поле не влезет**. Тип → `?string`, правило → `['required','string']`
  (для Webitel числовой id как строка работает и в URL `/queues/{id}/members`).
- `getQueuesForTelephonyAction()` резолвит не `WebitelQueueService`, а `DialerQueueDirectoryContract`.
- **Подтип расписания для Infinity не используется** (решение по Q6): у Infinity тип обзвона задаёт `kind`
  самой кампании. Селект подтипа в `schedule.blade.php:80-90` показывается для телефонии только на
  Webitel; правило `subtypeScheduleId => ['required','integer']` в `ScheduleComponent::rules()` тоже
  становится условным. Для Webitel всё остаётся как есть — там `subtype_id` уходит в `variables`.
- Хардкод-лейбл `Cola` в `schedule.blade.php:94` и тексты ошибок на испанском — вынести в `lang`
  и назвать «Кампания» для KZ. Косметика, на работоспособность не влияет.
- В `launch/index.blade.php:129` ссылка на бакет уже общая для телефонии — менять не нужно.

### 3.5 Мелочи, которые выстрелят на KZ

- Номер телефона для Infinity передаётся **как есть**, из `templates.telephone`, без добавления кода
  страны и прочих преобразований (решение по Q10).
- `getCountryPhoneCode()` (`functions.php:210`) для `kazakhstan` кидает `Exception` — ветки нет.
  В цепочке телефонии он не участвует (единственный вызов — `ReadPhonesRepository::getPhoneByNumberAndType()`
  из `ClientController`), так что задачу это не блокирует, но мина в соседнем коде остаётся.
- Учётка для cron-запусков — прежняя, `User::DEFAULT_TELEPHONY_USER_ID = 2` (решение по Q11).
  Для загрузки контактов в кампанию `dialer_extension_id` не нужен, он требуется только для click-to-call.
- Отбор телефона в `TelephonyCommunicationStrategy::createTemplateForClient()`
  (`confirmado = true`, `parentesco = 'Principal Titular'`, последний по id) **менять не нужно**:
  казахстанская схема совпадает с перуанской (решение по Q5).
- `TelephonyCommunicationStrategy::execute()` при пустом subtype/queue делает ранний выход **только
  под `isPeru()`**. Гвард нужно сделать провайдеро-зависимым: очередь/кампания обязательна всегда,
  подтип — только для Webitel (см. Q6). Иначе на KZ невалидная конфигурация дойдёт до провайдера
  и упадёт уже там, оставив запуск в `PROCESSING`.
- `(string) $additionalInfo['subtype_id']` в `WebitelTelephonyProvider::buildPayload()` — без null-проверки.
- `Calendar::isTodayNonWorking()` блокирует запуск: нужен заполненный календарь нерабочих дней для KZ.
- `config('polaris.telephony.enabled', true)` в `TelephonyLaunchService::resume()` — ключа
  `polaris.telephony` не существует, защита от обзвона с тестового окружения не работает.
  Для боевого дайлера KZ это стоит починить до релиза.

---

## 4. Порядок работ

Сделано: ~~тип `histories.communication_id` → строка~~ (миграция `2026_07_20_125524`).

1. **Рефакторинг абстракции** (без изменения поведения Перу): пункты таблицы 3.2 — снять `instanceof`
   из джобы, унести нормализацию номера в провайдер, убрать `queueId` из сигнатур в DTO, отвязать
   базовый класс от формата ответа Webitel и от Guzzle. Ручная проверка, что перуанский запуск цел.
2. **Справочник кампаний**: контракт + две реализации (`error`-кампании скрыты) + перевод
   `ScheduleComponent::$queueForTelephony` на строковый id.
3. **`InfinityTelephonyProvider`**: payload из четырёх полей, отправка через `Http`, разбор ответа,
   номер как есть. Регистрация в `match` по инстансу (`AppServiceProvider.php:147`).
4. **UI для KZ**: скрыть подтип для Infinity и сделать его валидацию условной, провайдеро-зависимый
   гвард в `TelephonyCommunicationStrategy::execute()`, лейбл «Кампания».
5. **Окружение KZ**: заполнить календарь нерабочих дней (иначе `Calendar::isTodayNonWorking()` заблокирует
   запуск), проверить, что `CompanyAction` `telephony` и подтипы накатаны на казахстанской БД.

**Явно вне объёма** (решения Q8, Q9, Q12): вебхуки `route_fin`/`dlg_stop` не дорабатываем — обратная связь
остаётся за ночным `InfinityCommunicationMigrator`; rate limit и режимы старта/остановки кампании
не рассматриваем; токен в репозитории не трогаем.

Тестов на этот модуль в репозитории нет вообще — по каждому пункту имеет смысл писать unit-тесты на
`buildPayload()` и feature-тест на `TelephonyBucketLauncherJob` с замоканным HTTP.

---

## 5. Вопросы и принятые решения

Все вопросы закрыты заказчиком 2026-07-20. Незакрытым остаётся только хвост Q1 (коды ошибок Infinity)
и отложенный Q3a — оба не блокируют старт работ.

**Q1. ЗАКРЫТ.** Ответ `POST SimpleContragents` получен (см. 2.2) — эхо объекта с `id` (UUID) и `increment`.
Признак успеха — наличие `id`. Тип колонки под него исправлен (см. 3.2.1). Остаётся уточнить **коды и тела
ошибок**: дубликат номера, несуществующая кампания, невалидный номер — чтобы отличать «отказ дайлера»
от «сети не было» и не хоронить контакт в `FAILED` без разбора.

**Q2. ЗАКРЫТ.** Кампании в состоянии `error` в выпадашку не попадают — фильтр `state !== 'error'`
в `InfinityCampaignDirectory`. Остальные состояния (`running`, `stoped`) показываем.

**Q3. ЗАКРЫТ — `ext` не используем.** Передавать дайлеру `documento` / `company_launch_id` незачем:
эти данные обратно не приезжают, и связь запуска с номером у нас целиком своя
(`company_launch_lists` → `telephony.templates` → `telephony.histories.additional_information`,
плюс внешний id контакта в `histories.communication_id`). Ни `WebitelCommunicationMigrator`, ни
`InfinityCommunicationMigrator` не читают `company_launch_id` — во всём приложении он используется только
локально (`LaunchListComponent`, `BaseCompanyService`). Клиента при разборе звонков Infinity резолвит
по номеру телефона через `14_telefonos` — документ в payload для этого не нужен.
Итоговый payload: `campaign_id`, `fio`, `phoneNumbers`, `notes` (ссылка на карточку для оператора).

**Q3a (остаточный).** Нужна ли отчётность «сколько дозвонов дал конкретный запуск компании»? Сейчас такой
атрибуции нет ни у Webitel, ни у Infinity — мигратор создаёт коммуникацию по клиенту, без привязки
к запуску. Если она понадобится, придётся возвращаться к передаче идентификатора запуска в дайлер
и проверять, сохраняет ли Infinity произвольные ключи в `ext` без настройки `extensionProperties`
на кампании (сейчас там `null`).

**Q4. ЗАКРЫТ — не решаем.** Дубликаты контактов в кампании допустимы, отдельная проверка «номер уже
загружен» на стороне Infinity не делается (фильтры в GET-коллекции всё равно не работают). Защита от
повторной заливки остаётся прежней и полностью локальной: `TelephonyBucketLauncherJob` отбрасывает
документы, у которых в текущем бакете уже есть запись в `telephony.histories` со статусом не `FAILED`.
Ретраи дозвона внутри кампании — зона ответственности дайлера (`maxTryCount`, `repeatRules`).

**Q5. ЗАКРЫТ.** Казахстанская схема данных совпадает с перуанской, правило отбора телефона
(`confirmado = true`, `parentesco = 'Principal Titular'`) переиспользуется без изменений.

**Q6. ЗАКРЫТ — подтипы для Infinity не нужны.** Тип обзвона задаёт `kind` кампании на стороне дайлера.
Селект подтипа и его валидация показываются для телефонии только на Webitel (см. 3.4);
`templates.additional_information->subtype_id` для Infinity остаётся пустым и в payload не попадает.
Перуанский путь не меняется.

**Q7. ЗАКРЫТ — Infinity только на KZ.** Инстансы не пересекаются: на Перу остаётся Webitel, на KZ —
Infinity. Поэтому провайдер выбирается привязкой к инстансу, настройка в `ApplicationSettings`
и колонка `provider` в `telephony.buckets` не нужны (см. 3.2). Лейблы для KZ — «Кампания», косметика.

**Q8. ЗАКРЫТ — вне объёма.** Вебхуки `route_fin`/`dlg_stop` не дорабатываем, заглушки в
`InfinityWebhookEventJob` остаются как есть. Обратная связь по звонкам — ночной
`InfinityCommunicationMigrator` (матчинг по номеру телефона), он уже написан.

**Q9. ЗАКРЫТ — не рассматриваем.** Ограничения дайлера (rate limit, размер кампании, старт/стоп вокруг
загрузки) в этой задаче не учитываем. Отправка остаётся по одному запросу на контакт.

**Q10. ЗАКРЫТ.** Номер передаём как есть, из `templates.telephone`, без нормализации и кода страны.

**Q11. ЗАКРЫТ.** Используем тот же `User::DEFAULT_TELEPHONY_USER_ID = 2`.
`dialer_extension_id` для загрузки контактов в кампанию не требуется.

**Q12. ЗАКРЫТ — не трогаем.** Захардкоженный дефолт `INFINITY_TOKEN` в `config/integrations.php:11`
остаётся как есть. Зафиксировано осознанно, на случай если вопрос всплывёт при security-ревью.