# Infinity Dialer в модуле компаний — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Запускать обзвон списков из модуля компаний через дайлер Infinity на казахстанском инстансе, не меняя поведение перуанского Webitel.

**Architecture:** Общий конвейер (`TelephonyCommunicationStrategy` → `TelephonyLaunchService` → `TelephonyBucketLauncherJob` → `Bucket`/`Template`/`History`) остаётся единым. Всё, что специфично для дайлера, уезжает за две абстракции: `AbstractTelephonyProvider` (сборка payload, отправка, нормализация номера, требуется ли подтип) и новый `DialerQueueDirectoryContract` (справочник очередей Webitel / кампаний Infinity). Обе резолвятся по `config('polaris.instance')` в `AppServiceProvider`, потому что инстансы не пересекаются: Перу — Webitel, Казахстан — Infinity.

**Tech Stack:** PHP 8.3 · Laravel 10 · Livewire 3 · PostgreSQL (`telephony`, `collection`, `users`) · Guzzle (Webitel) · `Http`-фасад (Infinity) · PHPUnit 10 · Pint.

**Спека:** `docs/superpowers/specs/2026-07-20-infinity-dialer-company-telephony-design.md`

## Global Constraints

- Все Artisan/Composer/PHPUnit/Pint команды — **внутри контейнера**: `docker exec -i polaris-service-new <cmd>`.
- **Коммиты:** по устойчивому предпочтению пользователя **НЕ коммитить** во время выполнения. Шаг «Checkpoint» в конце каждой задачи означает: прогнать Pint на изменённых файлах, прогнать тесты задачи, остановиться. Коммит — только по явной просьбе.
- **Pint:** `pint --dirty` в этом контейнере находит 0 файлов. Запускать на явных путях: `docker exec -i polaris-service-new vendor/bin/pint app/Services/... tests/...`.
- **Перуанский путь не меняется по поведению.** Любая правка общего кода обязана сохранить: префикс `51` для перуанских номеров, `subtype_id` в `variables` Webitel, URL `/api/call_center/queues/{queueId}/members`.
- Laravel 10: касты объявлять как `protected $casts = []` (метод `casts()` недоступен). Livewire-компоненты живут в `App\Livewire`.
- Не использовать `DB::` там, где подходит Eloquent. Явные типы возвращаемых значений и параметров обязательны. Фигурные скобки у всех управляющих конструкций.
- Payload Infinity — **ровно четыре поля**: `campaign_id`, `fio`, `phoneNumbers`, `notes`. Поле `ext` не заполнять.
- Номер телефона для Infinity передаётся **как есть**, из `templates.telephone`, без нормализации и кода страны.
- Учётка для cron-запусков — `User::DEFAULT_TELEPHONY_USER_ID = 2`, менять не нужно.
- Тесты, работающие с БД, используют `Illuminate\Foundation\Testing\DatabaseTransactions` и объявляют `protected $connectionsToTransact = [...]` — как в `tests/Feature/Integrations/Infinity/InfinityCallServiceTest.php`.
- Вне объёма: вебхуки `route_fin`/`dlg_stop`, rate limit, старт/стоп кампаний, ротация токена в `config/integrations.php`.

## Структура файлов

**Создаём:**

| Файл | Ответственность |
|---|---|
| `app/Services/CommunicationIntegrations/Telephony/DTO/DialerQueueDTO.php` | очередь/кампания в едином виде: `id` (строка), `name`, `kind` |
| `app/Services/CommunicationIntegrations/Telephony/Contracts/DialerQueueDirectoryContract.php` | контракт справочника целей обзвона |
| `app/Services/CommunicationIntegrations/Telephony/Directories/WebitelQueueDirectory.php` | обёртка над `WebitelQueueService` |
| `app/Services/CommunicationIntegrations/Telephony/Directories/InfinityCampaignDirectory.php` | `GET Campaigns` + фильтр `state !== 'error'` |
| `app/Services/CommunicationIntegrations/Telephony/Providers/InfinityTelephonyProvider.php` | payload и отправка контакта в кампанию Infinity |

**Модифицируем:**

| Файл | Что меняется |
|---|---|
| `app/Services/CommunicationIntegrations/Telephony/Providers/Abstract/TelephonyProviderContract.php` | сигнатуры без `queueId`; `supportsBatch`, `normalizePhoneNumber`, `requiresSubtype` |
| `app/Services/CommunicationIntegrations/Telephony/Providers/Abstract/AbstractTelephonyProvider.php` | те же сигнатуры; `request(): ResponseInterface`; `targetId` из DTO |
| `app/Services/CommunicationIntegrations/Telephony/Providers/WebitelTelephonyProvider.php` | инжект Guzzle-клиента, перенос нормализации номера, удаление дублей |
| `app/Services/CommunicationIntegrations/Telephony/Job/TelephonyBucketLauncherJob.php` | убрать `instanceof` и `formatPhoneNumber` |
| `app/Services/CommunicationIntegrations/Telephony/DTO/SendTelephonyDTO.php` | добавить `targetId` |
| `app/Services/Company/Communication/Strategies/TelephonyCommunicationStrategy.php` | провайдеро-зависимый гвард |
| `app/Livewire/Company/ScheduleComponent.php` | строковый id цели, условная валидация подтипа |
| `resources/views/livewire/company/schedule.blade.php` | подтип только когда требуется |
| `app/Providers/AppServiceProvider.php` | биндинги провайдера и справочника для `INSTANCE_KAZAKHSTAN` |

---

### Task 1: Джоба перестаёт зависеть от конкретного класса провайдера

Сейчас `TelephonyBucketLauncherJob::handle()` содержит `match (true) { $telephonyProvider instanceof WebitelTelephonyProvider => ... }` без ветки `default`. Любой новый провайдер даёт `UnhandledMatchError` внутри очереди. Решение о режиме отправки переезжает на провайдер.

**Files:**
- Modify: `app/Services/CommunicationIntegrations/Telephony/Providers/Abstract/TelephonyProviderContract.php`
- Modify: `app/Services/CommunicationIntegrations/Telephony/Providers/Abstract/AbstractTelephonyProvider.php`
- Modify: `app/Services/CommunicationIntegrations/Telephony/Job/TelephonyBucketLauncherJob.php:70-72`
- Test: `tests/Feature/Services/CommunicationIntegrations/Telephony/TelephonyBucketLauncherJobTest.php`

**Interfaces:**
- Produces: `TelephonyProviderContract::supportsBatch(): bool` — реализация по умолчанию в `AbstractTelephonyProvider` возвращает `false`.

- [x] **Step 1: Написать падающий тест**

Создать `tests/Feature/Services/CommunicationIntegrations/Telephony/TelephonyBucketLauncherJobTest.php`:

```php
<?php

namespace Tests\Feature\Services\CommunicationIntegrations\Telephony;

use App\Models\Telephony\Bucket;
use App\Models\Telephony\History;
use App\Models\Telephony\Template;
use App\Models\User\User;
use App\Services\CommunicationIntegrations\Telephony\DTO\SendTelephonyDTO;
use App\Services\CommunicationIntegrations\Telephony\Job\TelephonyBucketLauncherJob;
use App\Services\CommunicationIntegrations\Telephony\Providers\Abstract\AbstractTelephonyProvider;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;

class TelephonyBucketLauncherJobTest extends TestCase
{
    use DatabaseTransactions;

    protected $connectionsToTransact = ['telephony'];

    private function fakeProvider(): AbstractTelephonyProvider
    {
        return new class extends AbstractTelephonyProvider
        {
            protected static string $providerName = 'Fake';

            /** @var array<int, SendTelephonyDTO> */
            public array $sent = [];

            public function normalizePhoneNumber(string $phone): string
            {
                return $phone;
            }

            public function requiresSubtype(): bool
            {
                return false;
            }

            protected function buildPayload(SendTelephonyDTO $sendTelephonyDTO, string $queueId): array
            {
                $this->sent[] = $sendTelephonyDTO;

                return ['phone' => $sendTelephonyDTO->phoneNumber];
            }

            protected function request(array $payload, string $queueId): Response
            {
                return new Response(200, [], json_encode(['id' => 'fake-1']));
            }
        };
    }

    private function makeBucketWithTemplate(string $document, string $phone): Bucket
    {
        $bucket = Bucket::createInstance(777001, 1, User::DEFAULT_TELEPHONY_USER_ID, Bucket::INITIATOR_CRON);

        Template::query()->create([
            'user_id' => User::DEFAULT_TELEPHONY_USER_ID,
            'document' => $document,
            'telephone' => $phone,
            'additional_information' => [
                'company_launch_id' => 777001,
                'client_name' => 'Test Client',
                'client_id' => 42,
                'client_link' => 'https://example.test/admin/client/42',
                'subtype_id' => null,
                'queue_id' => 'queue-1',
            ],
        ]);

        return $bucket;
    }

    public function test_sends_every_template_through_provider_without_instanceof_checks(): void
    {
        $bucket = $this->makeBucketWithTemplate('DOC-1', '51999888777');
        $provider = $this->fakeProvider();

        $job = new TelephonyBucketLauncherJob(
            $bucket,
            User::DEFAULT_TELEPHONY_USER_ID,
            22,
            'cron',
            'queue-1',
            777001
        );

        $job->handle($provider);

        $this->assertCount(1, $provider->sent);
        $this->assertSame('51999888777', $provider->sent[0]->phoneNumber);
        $this->assertSame(1, History::query()->where('bucket_id', $bucket->id)->count());
    }

    public function test_skips_documents_already_sent_in_this_bucket(): void
    {
        $bucket = $this->makeBucketWithTemplate('DOC-2', '51999888777');

        History::query()->create([
            'document' => 'DOC-2',
            'telephone' => '51999888777',
            'bucket_id' => $bucket->id,
            'status' => History::STATUS_SUCCEED,
            'additional_information' => [],
        ]);

        $provider = $this->fakeProvider();

        (new TelephonyBucketLauncherJob(
            $bucket,
            User::DEFAULT_TELEPHONY_USER_ID,
            22,
            'cron',
            'queue-1',
            777001
        ))->handle($provider);

        $this->assertCount(0, $provider->sent);
    }
}
```

Фейковый провайдер намеренно реализует **текущие** сигнатуры (`buildPayload(dto, queueId)`, `request(payload, queueId): Response`) — они поменяются в Task 3, и тогда же тест будет обновлён. Так каждая задача остаётся зелёной на своём Checkpoint.

- [x] **Step 2: Запустить тест, убедиться что падает**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=TelephonyBucketLauncherJobTest`
Expected: FAIL — `UnhandledMatchError` в `handle()`: фейковый провайдер не является `WebitelTelephonyProvider`, а ветки `default` в `match` нет.

- [x] **Step 3: Добавить `supportsBatch()` в контракт и абстракцию**

В `TelephonyProviderContract` добавить объявление:

```php
public function supportsBatch(): bool;
```

В `AbstractTelephonyProvider` добавить реализацию по умолчанию:

```php
public function supportsBatch(): bool
{
    return false;
}
```

- [x] **Step 4: Заменить `match (true)` в джобе**

В `TelephonyBucketLauncherJob::handle()` заменить блок

```php
match (true) {
    $telephonyProvider instanceof WebitelTelephonyProvider => $this->sendNoButch($telephonyProvider, $sendDtos),
};
```

на

```php
if ($telephonyProvider->supportsBatch()) {
    $telephonyProvider->executeBatch($sendDtos, $this->queueId);

    return;
}

$this->sendOneByOne($telephonyProvider, $sendDtos);
```

Переименовать метод `sendNoButch()` в `sendOneByOne()` (опечатка в исходном названии), убрать импорт `WebitelTelephonyProvider`, убрать бесполезный `try { ... } catch (\Throwable $e) { throw $e; }` — оставить тело метода без обёртки.

- [x] **Step 5: Запустить тест**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=TelephonyBucketLauncherJobTest`
Expected: PASS, 2 теста.

- [x] **Step 6: Checkpoint**

```bash
docker exec -i polaris-service-new vendor/bin/pint \
  app/Services/CommunicationIntegrations/Telephony/Providers/Abstract \
  app/Services/CommunicationIntegrations/Telephony/Job
```

Не коммитить. Перейти к Task 2.

---

### Task 2: Нормализация номера переезжает в провайдер

`TelephonyBucketLauncherJob::formatPhoneNumber()` приклеивает перуанский код `51` в общем коде и содержит дублирующуюся ветку `str_starts_with($phone, '51')`. Для Infinity номер нужен как есть.

**Files:**
- Modify: `app/Services/CommunicationIntegrations/Telephony/Providers/Abstract/TelephonyProviderContract.php`
- Modify: `app/Services/CommunicationIntegrations/Telephony/Providers/Abstract/AbstractTelephonyProvider.php`
- Modify: `app/Services/CommunicationIntegrations/Telephony/Providers/WebitelTelephonyProvider.php`
- Modify: `app/Services/CommunicationIntegrations/Telephony/Job/TelephonyBucketLauncherJob.php`
- Test: `tests/Unit/Services/CommunicationIntegrations/Telephony/WebitelPhoneNormalizationTest.php`

**Interfaces:**
- Consumes: `AbstractTelephonyProvider` из Task 1.
- Produces: `TelephonyProviderContract::normalizePhoneNumber(string $phone): string` — абстрактный, каждый провайдер обязан реализовать.

- [x] **Step 1: Написать падающий тест**

Создать `tests/Unit/Services/CommunicationIntegrations/Telephony/WebitelPhoneNormalizationTest.php`:

```php
<?php

namespace Tests\Unit\Services\CommunicationIntegrations\Telephony;

use App\Services\CommunicationIntegrations\Telephony\Providers\WebitelTelephonyProvider;
use GuzzleHttp\Client;
use Tests\TestCase;

class WebitelPhoneNormalizationTest extends TestCase
{
    private function provider(): WebitelTelephonyProvider
    {
        return new WebitelTelephonyProvider(new Client());
    }

    public function test_adds_peru_country_code_to_nine_digit_mobile(): void
    {
        $this->assertSame('51999888777', $this->provider()->normalizePhoneNumber('999888777'));
    }

    public function test_keeps_number_that_already_has_country_code(): void
    {
        $this->assertSame('51999888777', $this->provider()->normalizePhoneNumber('51999888777'));
    }

    public function test_strips_non_digit_characters(): void
    {
        $this->assertSame('51999888777', $this->provider()->normalizePhoneNumber('+51 (999) 888-777'));
    }
}
```

- [x] **Step 2: Запустить тест, убедиться что падает**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=WebitelPhoneNormalizationTest`
Expected: FAIL — метод `normalizePhoneNumber` не существует, конструктор не принимает `Client`.

- [x] **Step 3: Объявить метод в контракте и абстракции**

В `TelephonyProviderContract`:

```php
public function normalizePhoneNumber(string $phone): string;
```

В `AbstractTelephonyProvider` рядом с остальными абстрактными методами:

```php
abstract public function normalizePhoneNumber(string $phone): string;
```

- [x] **Step 4: Реализовать в `WebitelTelephonyProvider`**

Заменить конструктор, чтобы клиент инжектился (нужно и для тестов, и для Task 4):

```php
public function __construct(protected Client $client)
{
}
```

Добавить публичный метод с перенесённой из джобы логикой, без дублирующейся ветки:

```php
public function normalizePhoneNumber(string $phone): string
{
    $digits = preg_replace('/[^0-9]/', '', $phone);

    if (strlen($digits) === 9 && str_starts_with($digits, '9')) {
        return '51'.$digits;
    }

    return $digits;
}
```

- [x] **Step 5: Зарегистрировать провайдер с клиентом**

В `AppServiceProvider::resolveWebitelServices()` добавить биндинг рядом с существующими (`WebitelCallService`, `WebitelQueueService`):

```php
$this->app->singleton(WebitelTelephonyProvider::class, function (): WebitelTelephonyProvider {
    $client = new Client([
        'base_uri' => config('integrations.webitel.base_uri', 'https://webitel.doctorsol.pe/api'),
        'headers' => [
            'X-Webitel-Access' => config('integrations.webitel.access_token'),
            'Content-Type' => 'application/json',
        ],
        'timeout' => 30,
        'connect_timeout' => 10,
    ]);

    return new WebitelTelephonyProvider($client);
});
```

Импорт `use GuzzleHttp\Client;` в `AppServiceProvider` уже есть — проверить, при необходимости добавить.

- [x] **Step 6: Переключить джобу на провайдер**

В `TelephonyBucketLauncherJob::handle()` заменить `$this->formatPhoneNumber($template->telephone)` на `$telephonyProvider->normalizePhoneNumber($template->telephone)`. Удалить метод `formatPhoneNumber()` целиком.

- [x] **Step 7: Запустить тест**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=WebitelPhoneNormalizationTest`
Expected: PASS, 3 теста.

- [x] **Step 8: Checkpoint**

```bash
docker exec -i polaris-service-new vendor/bin/pint \
  app/Services/CommunicationIntegrations/Telephony \
  app/Providers/AppServiceProvider.php \
  tests/Unit/Services/CommunicationIntegrations/Telephony
```

Не коммитить.

---

### Task 3: `queueId` уходит внутрь `SendTelephonyDTO`

Понятие «очередь» протекло в публичные сигнатуры всей цепочки. Для Infinity цель — кампания, для будущих дайлеров — что-то ещё. Переносим идентификатор цели в DTO под нейтральным именем `targetId`.

**Files:**
- Modify: `app/Services/CommunicationIntegrations/Telephony/DTO/SendTelephonyDTO.php`
- Modify: `app/Services/CommunicationIntegrations/Telephony/Providers/Abstract/TelephonyProviderContract.php`
- Modify: `app/Services/CommunicationIntegrations/Telephony/Providers/Abstract/AbstractTelephonyProvider.php`
- Modify: `app/Services/CommunicationIntegrations/Telephony/Providers/WebitelTelephonyProvider.php`
- Modify: `app/Services/CommunicationIntegrations/Telephony/Job/TelephonyBucketLauncherJob.php`
- Test: `tests/Feature/Services/CommunicationIntegrations/Telephony/TelephonyBucketLauncherJobTest.php` (из Task 1)

**Interfaces:**
- Produces:
  - `SendTelephonyDTO::$targetId` (`string`) — идентификатор очереди/кампании.
  - `TelephonyProviderContract::execute(SendTelephonyDTO $sendTelephonyDTO): History`
  - `TelephonyProviderContract::executeBatch(array $sendDtos): Collection`
  - `AbstractTelephonyProvider::buildPayload(SendTelephonyDTO $sendTelephonyDTO): array` (protected)
  - `AbstractTelephonyProvider::request(array $payload, string $targetId): ResponseInterface` (protected)

- [x] **Step 1: Добавить `targetId` в DTO**

В `SendTelephonyDTO::__construct()` добавить параметр после `$channel`:

```php
public string $targetId,
```

Итоговый конструктор:

```php
public function __construct(
    public int $bucketId,
    public int $companyLaunchId,
    public string $phoneNumber,
    public string $clientName,
    public string $document,
    public string $clientLink,
    public int $clientId,
    public int $resultId,
    public int $agentId,
    public string $channel,
    public string $targetId,
    public array $additionalInfo = []
) {
}
```

- [x] **Step 2: Обновить контракт**

```php
public function execute(SendTelephonyDTO $sendTelephonyDTO): History;

public function executeBatch(array $sendDtos): Collection;
```

- [x] **Step 3: Обновить абстракцию**

В `AbstractTelephonyProvider`:

```php
abstract protected function buildPayload(SendTelephonyDTO $sendTelephonyDTO): array;

abstract protected function request(array $payload, string $targetId): ResponseInterface;

public function execute(SendTelephonyDTO $sendTelephonyDTO): History
{
    $history = $this->createHistory($sendTelephonyDTO);

    try {
        $payload = $this->buildPayload($sendTelephonyDTO);
        $response = $this->request($payload, $sendTelephonyDTO->targetId);
        $responseContents = $response->getBody()->getContents();
        $responseData = json_decode($responseContents, true);

        $this->processResponse($history, is_array($responseData) ? $responseData : [], $responseContents);
        $history->saveOrFail();
    } catch (\Throwable $exception) {
        $history->status = History::STATUS_FAILED;
        $history->response = $exception->getMessage();
        $history->saveOrFail();

        Log::channel('sentry')->error($exception);
    }

    return $history;
}
```

В `executeBatch()` убрать параметр `$queueId`, брать цель из первого DTO пачки:

```php
public function executeBatch(array $sendDtos): Collection
{
    $results = collect();
    $batches = collect($sendDtos)->chunk($this->getBatchSize());

    foreach ($batches as $batch) {
        $targetId = $batch->first()->targetId;

        try {
            $payloads = [];
            foreach ($batch as $dto) {
                $payloads[] = $this->buildPayload($dto);
            }

            $response = $this->requestBatch($payloads, $targetId);
            $responseContents = $response->getBody()->getContents();
            $responseData = json_decode($responseContents, true);

            foreach ($batch as $index => $dto) {
                $history = $this->createHistory($dto);

                $itemResponse = $responseData['items'][$index] ?? null;
                $this->processBatchResponse($history, $itemResponse, $responseContents);
                $history->saveOrFail();
                $results->push($history);
            }
        } catch (\Throwable $exception) {
            foreach ($batch as $dto) {
                $history = $this->createHistory($dto);
                $history->status = History::STATUS_FAILED;
                $history->response = $exception->getMessage();
                $history->saveOrFail();
                $results->push($history);
            }
            Log::channel('sentry')->error($exception);
        }
    }

    return $results;
}
```

`createHistory()` теряет параметр и берёт цель из DTO:

```php
protected function createHistory(SendTelephonyDTO $dto): History
{
    $history = new History();
    $history->document = $dto->document;
    $history->telephone = $dto->phoneNumber;
    $history->bucket_id = $dto->bucketId;
    $history->status = History::STATUS_WAITING;
    $history->additional_information = [
        'client_name' => $dto->clientName,
        'client_id' => $dto->clientId,
        'client_link' => $dto->clientLink,
        'queue_id' => $dto->targetId,
        'result_id' => $dto->resultId,
        'agent_id' => $dto->agentId,
        'channel' => $dto->channel,
        'company_launch_id' => $dto->companyLaunchId,
    ];

    return $history;
}
```

`requestBatch()` оставить с сигнатурой `protected function requestBatch(array $payloads, string $targetId): ResponseInterface`.

- [x] **Step 4: Обновить `WebitelTelephonyProvider`**

```php
protected function buildPayload(SendTelephonyDTO $sendTelephonyDTO): array
{
    return [
        'name' => $sendTelephonyDTO->clientName,
        'communications' => [
            [
                'destination' => $sendTelephonyDTO->phoneNumber,
                'type' => ['id' => '1'],
            ],
        ],
        'variables' => [
            'link' => $sendTelephonyDTO->clientLink,
            'document' => $sendTelephonyDTO->document,
            'subtype_id' => (string) ($sendTelephonyDTO->additionalInfo['subtype_id'] ?? ''),
        ],
    ];
}

protected function request(array $payload, string $targetId): ResponseInterface
{
    return $this->client->post("/api/call_center/queues/{$targetId}/members", ['json' => $payload]);
}

protected function requestBatch(array $payloads, string $targetId): ResponseInterface
{
    $items = [];

    foreach ($payloads as $payload) {
        if (isset($payload['items'])) {
            $items = array_merge($items, $payload['items']);

            continue;
        }

        $items[] = $payload;
    }

    return $this->client->post("/api/call_center/queues/{$targetId}/members", ['json' => ['items' => $items]]);
}
```

Обратить внимание: `subtype_id` теперь через `??  ''` — падения на `null` больше нет. Для Перу поведение прежнее, потому что подтип там всегда заполнен.

- [x] **Step 5: Обновить джобу**

В `handle()` при сборке DTO добавить `targetId: $this->queueId,` и вызывать провайдер без второго аргумента:

```php
$sendDtos = $filteredTemplates->map(function (Template $template) use ($telephonyProvider) {
    $additionalInfo = $template->additional_information ?? [];

    return new SendTelephonyDTO(
        bucketId: $this->bucket->id,
        companyLaunchId: $this->companyLaunchId,
        phoneNumber: $telephonyProvider->normalizePhoneNumber($template->telephone),
        clientName: $additionalInfo['client_name'],
        document: $template->document,
        clientLink: $additionalInfo['client_link'] ?? $this->buildClientLink($template->document),
        clientId: $additionalInfo['client_id'] ?? 0,
        resultId: $this->resultId,
        agentId: $this->agentId,
        channel: $this->channel,
        targetId: $this->queueId,
        additionalInfo: $additionalInfo
    );
})->toArray();

if ($telephonyProvider->supportsBatch()) {
    $telephonyProvider->executeBatch($sendDtos);

    return;
}

$this->sendOneByOne($telephonyProvider, $sendDtos);
```

И сам метод:

```php
protected function sendOneByOne(AbstractTelephonyProvider $telephonyProvider, array $sendDtos): void
{
    foreach ($sendDtos as $dto) {
        $telephonyProvider->execute($dto);
    }
}
```

- [x] **Step 6: Обновить фейковый провайдер в тесте Task 1**

В `TelephonyBucketLauncherJobTest::fakeProvider()` привести анонимный класс к новым сигнатурам:

```php
protected function buildPayload(SendTelephonyDTO $sendTelephonyDTO): array
{
    $this->sent[] = $sendTelephonyDTO;

    return ['phone' => $sendTelephonyDTO->phoneNumber];
}

protected function request(array $payload, string $targetId): ResponseInterface
{
    return new Response(200, [], json_encode(['id' => 'fake-1']));
}
```

Добавить импорт `use Psr\Http\Message\ResponseInterface;`. В тест `test_sends_every_template_through_provider_without_instanceof_checks` дописать проверку, что цель доехала до DTO:

```php
$this->assertSame('queue-1', $provider->sent[0]->targetId);
```

- [x] **Step 7: Запустить тесты Task 1 и Task 2**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter='TelephonyBucketLauncherJobTest|WebitelPhoneNormalizationTest'`
Expected: PASS, 5 тестов.

- [x] **Step 8: Checkpoint**

```bash
docker exec -i polaris-service-new vendor/bin/pint \
  app/Services/CommunicationIntegrations/Telephony \
  tests/Feature/Services/CommunicationIntegrations/Telephony
```

Не коммитить.

---

### Task 4: Отвязать абстракцию от Guzzle и убрать дубли в Webitel

`request()` типизирован конкретным `GuzzleHttp\Psr7\Response`, из-за чего провайдер на `Http`-фасаде (а весь Infinity-код в проекте написан именно так) не подходит. Плюс `WebitelTelephonyProvider` содержит дословные копии `processResponse()` и `processBatchResponse()` из родителя.

**Files:**
- Modify: `app/Services/CommunicationIntegrations/Telephony/Providers/Abstract/AbstractTelephonyProvider.php`
- Modify: `app/Services/CommunicationIntegrations/Telephony/Providers/WebitelTelephonyProvider.php`
- Test: `tests/Feature/Services/CommunicationIntegrations/Telephony/WebitelTelephonyProviderTest.php`

**Interfaces:**
- Consumes: сигнатуры из Task 3.
- Produces: `AbstractTelephonyProvider::request(array $payload, string $targetId): \Psr\Http\Message\ResponseInterface`.

- [x] **Step 1: Написать падающий тест**

Создать `tests/Feature/Services/CommunicationIntegrations/Telephony/WebitelTelephonyProviderTest.php`:

```php
<?php

namespace Tests\Feature\Services\CommunicationIntegrations\Telephony;

use App\Models\Telephony\History;
use App\Services\CommunicationIntegrations\Telephony\DTO\SendTelephonyDTO;
use App\Services\CommunicationIntegrations\Telephony\Providers\WebitelTelephonyProvider;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;

class WebitelTelephonyProviderTest extends TestCase
{
    use DatabaseTransactions;

    protected $connectionsToTransact = ['telephony'];

    /** @var array<int, array<string, mixed>> */
    private array $recorded = [];

    private function providerReturning(Response $response): WebitelTelephonyProvider
    {
        $mock = new MockHandler([$response]);
        $stack = HandlerStack::create($mock);
        $stack->push(function (callable $handler) {
            return function (Request $request, array $options) use ($handler) {
                $this->recorded[] = [
                    'uri' => (string) $request->getUri(),
                    'body' => json_decode((string) $request->getBody(), true),
                ];

                return $handler($request, $options);
            };
        });

        return new WebitelTelephonyProvider(new Client(['handler' => $stack]));
    }

    private function dto(): SendTelephonyDTO
    {
        return new SendTelephonyDTO(
            bucketId: 1,
            companyLaunchId: 777002,
            phoneNumber: '51999888777',
            clientName: 'Juan Perez',
            document: 'DOC-9',
            clientLink: 'https://example.test/admin/client/9',
            clientId: 9,
            resultId: 22,
            agentId: 2,
            channel: 'cron',
            targetId: '15',
            additionalInfo: ['subtype_id' => 6]
        );
    }

    public function test_posts_member_to_queue_and_marks_history_succeed(): void
    {
        $provider = $this->providerReturning(new Response(200, [], json_encode(['id' => 12345])));

        $history = $provider->execute($this->dto());

        $this->assertSame(History::STATUS_SUCCEED, $history->status);
        $this->assertSame('12345', (string) $history->communication_id);

        $this->assertStringEndsWith('/api/call_center/queues/15/members', $this->recorded[0]['uri']);
        $this->assertSame('51999888777', $this->recorded[0]['body']['communications'][0]['destination']);
        $this->assertSame('6', $this->recorded[0]['body']['variables']['subtype_id']);
        $this->assertSame('DOC-9', $this->recorded[0]['body']['variables']['document']);
    }

    public function test_marks_history_failed_when_response_has_no_id(): void
    {
        $provider = $this->providerReturning(new Response(200, [], json_encode(['error' => 'bad request'])));

        $history = $provider->execute($this->dto());

        $this->assertSame(History::STATUS_FAILED, $history->status);
    }
}
```

- [x] **Step 2: Запустить тест, убедиться что падает**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=WebitelTelephonyProviderTest`
Expected: FAIL — возвращаемый тип `request()` не совпадает с `ResponseInterface` мок-хендлера.

- [x] **Step 3: Сменить тип возвращаемого значения**

В `AbstractTelephonyProvider` заменить импорт `use GuzzleHttp\Psr7\Response;` на `use Psr\Http\Message\ResponseInterface;` и поправить сигнатуры `request()` и `requestBatch()`. То же самое в `WebitelTelephonyProvider`.

- [x] **Step 4: Удалить дубли в `WebitelTelephonyProvider`**

Удалить методы `processResponse()` и `processBatchResponse()` целиком — они байт в байт повторяют родительские. Удалить ставший ненужным импорт `use App\Models\Telephony\History;`, если он больше не используется, и `use Illuminate\Support\Facades\Log;`.

- [x] **Step 5: Запустить тест**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=WebitelTelephonyProviderTest`
Expected: PASS, 2 теста.

- [x] **Step 6: Прогнать всю телефонию**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter='Telephony|Webitel'`
Expected: PASS, 7 тестов.

- [x] **Step 7: Checkpoint**

```bash
docker exec -i polaris-service-new vendor/bin/pint \
  app/Services/CommunicationIntegrations/Telephony \
  tests/Feature/Services/CommunicationIntegrations/Telephony
```

Не коммитить.

---

### Task 5: Справочник очередей/кампаний за общим контрактом

UI сейчас напрямую резолвит `WebitelQueueService`. Нужен общий справочник с идентификатором-строкой (у Webitel id числовой, у Infinity — UUID) и фильтром кампаний в состоянии `error`.

**Files:**
- Create: `app/Services/CommunicationIntegrations/Telephony/DTO/DialerQueueDTO.php`
- Create: `app/Services/CommunicationIntegrations/Telephony/Contracts/DialerQueueDirectoryContract.php`
- Create: `app/Services/CommunicationIntegrations/Telephony/Directories/WebitelQueueDirectory.php`
- Create: `app/Services/CommunicationIntegrations/Telephony/Directories/InfinityCampaignDirectory.php`
- Modify: `app/Providers/AppServiceProvider.php`
- Test: `tests/Feature/Services/CommunicationIntegrations/Telephony/InfinityCampaignDirectoryTest.php`

**Interfaces:**
- Produces:
  - `DialerQueueDTO` — конструктор `(string $id, string $name, ?string $kind = null)`, публичные readonly-свойства.
  - `DialerQueueDirectoryContract::queuesForCompanies(): \Illuminate\Support\Collection` — коллекция `DialerQueueDTO`.

- [x] **Step 1: Написать падающий тест**

Создать `tests/Feature/Services/CommunicationIntegrations/Telephony/InfinityCampaignDirectoryTest.php`:

```php
<?php

namespace Tests\Feature\Services\CommunicationIntegrations\Telephony;

use App\Services\CommunicationIntegrations\Telephony\Directories\InfinityCampaignDirectory;
use App\Services\CommunicationIntegrations\Telephony\DTO\DialerQueueDTO;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class InfinityCampaignDirectoryTest extends TestCase
{
    public function test_maps_campaigns_and_hides_broken_ones(): void
    {
        Http::fake([
            '*/rest/v1/model/callcenter/outbound/Campaigns' => Http::response([
                ['id' => 'uuid-running', 'name' => '[КЦ] Заявки', 'kind' => 'predictive', 'state' => 'running'],
                ['id' => 'uuid-stopped', 'name' => '[КЦ] Напоминание', 'kind' => 'progressive', 'state' => 'stoped'],
                ['id' => 'uuid-broken', 'name' => 'Oleg Test', 'kind' => 'manual', 'state' => 'error'],
            ], 200),
        ]);

        $queues = resolve(InfinityCampaignDirectory::class)->queuesForCompanies();

        $this->assertCount(2, $queues);
        $this->assertContainsOnlyInstancesOf(DialerQueueDTO::class, $queues);
        $this->assertSame(['uuid-running', 'uuid-stopped'], $queues->pluck('id')->all());
        $this->assertSame('predictive', $queues->first()->kind);

        Http::assertSent(function (Request $request): bool {
            return str_contains($request->url(), '/rest/v1/model/callcenter/outbound/Campaigns')
                && $request->hasHeader('Authorization', 'Bearer '.config('integrations.infinity.token'));
        });
    }

    public function test_returns_empty_collection_when_api_returns_nothing(): void
    {
        Http::fake([
            '*/rest/v1/model/callcenter/outbound/Campaigns' => Http::response([], 200),
        ]);

        $this->assertTrue(resolve(InfinityCampaignDirectory::class)->queuesForCompanies()->isEmpty());
    }
}
```

- [x] **Step 2: Запустить тест, убедиться что падает**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=InfinityCampaignDirectoryTest`
Expected: FAIL — класс `InfinityCampaignDirectory` не существует.

- [x] **Step 3: Создать DTO**

```bash
docker exec -i polaris-service-new php artisan make:class Services/CommunicationIntegrations/Telephony/DTO/DialerQueueDTO --no-interaction
```

Содержимое:

```php
<?php

namespace App\Services\CommunicationIntegrations\Telephony\DTO;

class DialerQueueDTO
{
    public function __construct(
        public readonly string $id,
        public readonly string $name,
        public readonly ?string $kind = null
    ) {
    }
}
```

- [x] **Step 4: Создать контракт**

Создать `app/Services/CommunicationIntegrations/Telephony/Contracts/DialerQueueDirectoryContract.php`:

```php
<?php

namespace App\Services\CommunicationIntegrations\Telephony\Contracts;

use Illuminate\Support\Collection;

interface DialerQueueDirectoryContract
{
    /**
     * Цели обзвона, доступные для выбора в расписании компании.
     *
     * @return Collection<int, \App\Services\CommunicationIntegrations\Telephony\DTO\DialerQueueDTO>
     */
    public function queuesForCompanies(): Collection;
}
```

- [x] **Step 5: Реализация для Webitel**

Создать `app/Services/CommunicationIntegrations/Telephony/Directories/WebitelQueueDirectory.php`:

```php
<?php

namespace App\Services\CommunicationIntegrations\Telephony\Directories;

use App\Integrations\Webitel\Models\WebitelQueue;
use App\Integrations\Webitel\Services\WebitelQueueService;
use App\Services\CommunicationIntegrations\Telephony\Contracts\DialerQueueDirectoryContract;
use App\Services\CommunicationIntegrations\Telephony\DTO\DialerQueueDTO;
use Illuminate\Support\Collection;

class WebitelQueueDirectory implements DialerQueueDirectoryContract
{
    public function __construct(protected WebitelQueueService $webitelQueueService)
    {
    }

    public function queuesForCompanies(): Collection
    {
        return $this->webitelQueueService->getAllQueues()
            ->map(fn (WebitelQueue $queue) => new DialerQueueDTO((string) $queue->id, $queue->name));
    }
}
```

- [x] **Step 6: Реализация для Infinity**

Создать `app/Services/CommunicationIntegrations/Telephony/Directories/InfinityCampaignDirectory.php`:

```php
<?php

namespace App\Services\CommunicationIntegrations\Telephony\Directories;

use App\Services\CommunicationIntegrations\Telephony\Contracts\DialerQueueDirectoryContract;
use App\Services\CommunicationIntegrations\Telephony\DTO\DialerQueueDTO;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;

class InfinityCampaignDirectory implements DialerQueueDirectoryContract
{
    private const CAMPAIGNS_PATH = '/rest/v1/model/callcenter/outbound/Campaigns';

    private const HIDDEN_STATE = 'error';

    public function queuesForCompanies(): Collection
    {
        $response = Http::withToken(config('integrations.infinity.token'))
            ->acceptJson()
            ->timeout(30)
            ->get($this->endpoint())
            ->throw();

        return collect($response->json())
            ->reject(fn (array $campaign) => ($campaign['state'] ?? null) === self::HIDDEN_STATE)
            ->map(fn (array $campaign) => new DialerQueueDTO(
                (string) $campaign['id'],
                (string) $campaign['name'],
                isset($campaign['kind']) ? (string) $campaign['kind'] : null
            ))
            ->values();
    }

    private function endpoint(): string
    {
        return rtrim((string) config('integrations.infinity.base_uri'), '/').self::CAMPAIGNS_PATH;
    }
}
```

- [x] **Step 7: Забиндить справочник по инстансу**

В `AppServiceProvider::register()` рядом с биндингом `AbstractTelephonyProvider`:

```php
$this->app->singleton(DialerQueueDirectoryContract::class, function (Application $application) {
    $directoryClass = match (config('polaris.instance')) {
        INSTANCE_PERU => WebitelQueueDirectory::class,
        INSTANCE_KAZAKHSTAN => InfinityCampaignDirectory::class,
        default => throw new \Exception('Dialer queue directory is not configured for this instance'),
    };

    return $application->make($directoryClass);
});
```

- [x] **Step 8: Запустить тест**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=InfinityCampaignDirectoryTest`
Expected: PASS, 2 теста.

- [x] **Step 9: Checkpoint**

```bash
docker exec -i polaris-service-new vendor/bin/pint \
  app/Services/CommunicationIntegrations/Telephony \
  app/Providers/AppServiceProvider.php \
  tests/Feature/Services/CommunicationIntegrations/Telephony
```

Не коммитить.

---

### Task 6: `InfinityTelephonyProvider`

**Files:**
- Create: `app/Services/CommunicationIntegrations/Telephony/Providers/InfinityTelephonyProvider.php`
- Modify: `app/Providers/AppServiceProvider.php:147-153`
- Test: `tests/Feature/Services/CommunicationIntegrations/Telephony/InfinityTelephonyProviderTest.php`

**Interfaces:**
- Consumes: `AbstractTelephonyProvider` (Tasks 1–4), `SendTelephonyDTO::$targetId` (Task 3).
- Produces: `InfinityTelephonyProvider` с `requiresSubtype(): false`, `supportsBatch(): false`, номером без нормализации.

- [x] **Step 1: Написать падающий тест**

Создать `tests/Feature/Services/CommunicationIntegrations/Telephony/InfinityTelephonyProviderTest.php`:

```php
<?php

namespace Tests\Feature\Services\CommunicationIntegrations\Telephony;

use App\Models\Telephony\History;
use App\Services\CommunicationIntegrations\Telephony\DTO\SendTelephonyDTO;
use App\Services\CommunicationIntegrations\Telephony\Providers\InfinityTelephonyProvider;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class InfinityTelephonyProviderTest extends TestCase
{
    use DatabaseTransactions;

    protected $connectionsToTransact = ['telephony'];

    private function dto(string $phone = '79001234567'): SendTelephonyDTO
    {
        return new SendTelephonyDTO(
            bucketId: 1,
            companyLaunchId: 777003,
            phoneNumber: $phone,
            clientName: 'Тестов Тест Тестович',
            document: 'DOC-KZ-1',
            clientLink: 'https://example.test/admin/client/77',
            clientId: 77,
            resultId: 22,
            agentId: 2,
            channel: 'cron',
            targetId: '5a100e1f-7988-493f-98a1-7f4a026f8aec',
            additionalInfo: ['subtype_id' => null]
        );
    }

    public function test_posts_contact_with_exactly_four_fields(): void
    {
        Http::fake([
            '*/rest/v1/model/callcenter/outbound/SimpleContragents' => Http::response([
                'campaign_id' => '5a100e1f-7988-493f-98a1-7f4a026f8aec',
                'fio' => 'Тестов Тест Тестович',
                'id' => 'd293fea4-ea8e-405b-b3fb-30c7843b9465',
                'increment' => 3250,
                'notes' => 'https://example.test/admin/client/77',
                'phoneNumbers' => [['kind' => 'mobile', 'phoneNumber' => '79001234567']],
            ], 200),
        ]);

        $history = resolve(InfinityTelephonyProvider::class)->execute($this->dto());

        $this->assertSame(History::STATUS_SUCCEED, $history->status);
        $this->assertSame('d293fea4-ea8e-405b-b3fb-30c7843b9465', $history->communication_id);

        Http::assertSent(function (Request $request): bool {
            $body = $request->data();

            return str_ends_with($request->url(), '/rest/v1/model/callcenter/outbound/SimpleContragents')
                && $request->hasHeader('Authorization', 'Bearer '.config('integrations.infinity.token'))
                && array_keys($body) === ['campaign_id', 'fio', 'phoneNumbers', 'notes']
                && $body['campaign_id'] === '5a100e1f-7988-493f-98a1-7f4a026f8aec'
                && $body['fio'] === 'Тестов Тест Тестович'
                && $body['notes'] === 'https://example.test/admin/client/77'
                && $body['phoneNumbers'] === [['kind' => 'mobile', 'phoneNumber' => '79001234567']];
        });
    }

    public function test_sends_phone_number_as_is(): void
    {
        Http::fake([
            '*/rest/v1/model/callcenter/outbound/SimpleContragents' => Http::response(['id' => 'x-1'], 200),
        ]);

        $provider = resolve(InfinityTelephonyProvider::class);

        $this->assertSame('87001234567', $provider->normalizePhoneNumber('87001234567'));

        $provider->execute($this->dto('87001234567'));

        Http::assertSent(function (Request $request): bool {
            return $request->data()['phoneNumbers'][0]['phoneNumber'] === '87001234567';
        });
    }

    public function test_marks_history_failed_when_api_returns_error(): void
    {
        Http::fake([
            '*/rest/v1/model/callcenter/outbound/SimpleContragents' => Http::response(['msg' => 'campaign not found'], 404),
        ]);

        $history = resolve(InfinityTelephonyProvider::class)->execute($this->dto());

        $this->assertSame(History::STATUS_FAILED, $history->status);
    }

    public function test_does_not_require_subtype(): void
    {
        $this->assertFalse(resolve(InfinityTelephonyProvider::class)->requiresSubtype());
    }
}
```

- [x] **Step 2: Запустить тест, убедиться что падает**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=InfinityTelephonyProviderTest`
Expected: FAIL — класс `InfinityTelephonyProvider` не существует.

- [x] **Step 3: Создать провайдер**

Создать `app/Services/CommunicationIntegrations/Telephony/Providers/InfinityTelephonyProvider.php`:

```php
<?php

namespace App\Services\CommunicationIntegrations\Telephony\Providers;

use App\Services\CommunicationIntegrations\Telephony\DTO\SendTelephonyDTO;
use App\Services\CommunicationIntegrations\Telephony\Providers\Abstract\AbstractTelephonyProvider;
use Illuminate\Support\Facades\Http;
use Psr\Http\Message\ResponseInterface;

class InfinityTelephonyProvider extends AbstractTelephonyProvider
{
    protected static string $providerName = 'Infinity';

    private const CONTRAGENTS_PATH = '/rest/v1/model/callcenter/outbound/SimpleContragents';

    private const PHONE_KIND = 'mobile';

    /**
     * Infinity принимает номер в том виде, в каком он лежит в карточке клиента.
     */
    public function normalizePhoneNumber(string $phone): string
    {
        return $phone;
    }

    /**
     * Тип обзвона задаётся kind самой кампании на стороне дайлера.
     */
    public function requiresSubtype(): bool
    {
        return false;
    }

    /**
     * @return array{campaign_id: string, fio: string, phoneNumbers: array<int, array{kind: string, phoneNumber: string}>, notes: string}
     */
    protected function buildPayload(SendTelephonyDTO $sendTelephonyDTO): array
    {
        return [
            'campaign_id' => $sendTelephonyDTO->targetId,
            'fio' => $sendTelephonyDTO->clientName,
            'phoneNumbers' => [
                [
                    'kind' => self::PHONE_KIND,
                    'phoneNumber' => $sendTelephonyDTO->phoneNumber,
                ],
            ],
            'notes' => $sendTelephonyDTO->clientLink,
        ];
    }

    protected function request(array $payload, string $targetId): ResponseInterface
    {
        return Http::withToken(config('integrations.infinity.token'))
            ->acceptJson()
            ->asJson()
            ->timeout(30)
            ->post($this->endpoint(), $payload)
            ->throw()
            ->toPsrResponse();
    }

    private function endpoint(): string
    {
        return rtrim((string) config('integrations.infinity.base_uri'), '/').self::CONTRAGENTS_PATH;
    }
}
```

- [x] **Step 4: Зарегистрировать провайдер для казахстанского инстанса**

В `AppServiceProvider::register()` дополнить существующий `match`:

```php
$this->app->singleton(AbstractTelephonyProvider::class, function (Application $application, array $params) {
    $providerClass = match (config('polaris.instance')) {
        INSTANCE_PERU => WebitelTelephonyProvider::class,
        INSTANCE_KAZAKHSTAN => InfinityTelephonyProvider::class,
        default => throw new \Exception('Telephony provider is not configured for this instance'),
    };

    return resolve($providerClass);
});
```

Добавить импорт `use App\Services\CommunicationIntegrations\Telephony\Providers\InfinityTelephonyProvider;`.

- [x] **Step 5: Запустить тест**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=InfinityTelephonyProviderTest`
Expected: PASS, 4 теста.

- [x] **Step 6: Checkpoint**

```bash
docker exec -i polaris-service-new vendor/bin/pint \
  app/Services/CommunicationIntegrations/Telephony \
  app/Providers/AppServiceProvider.php \
  tests/Feature/Services/CommunicationIntegrations/Telephony
```

Не коммитить.

---

### Task 7: `requiresSubtype()` в контракте и провайдеро-зависимый гвард стратегии

`TelephonyCommunicationStrategy::execute()` сейчас валидирует конфигурацию только под `isPeru()`. На KZ пустая кампания дойдёт до провайдера, упадёт там, и запуск останется в статусе `PROCESSING`.

**Files:**
- Modify: `app/Services/CommunicationIntegrations/Telephony/Providers/Abstract/TelephonyProviderContract.php`
- Modify: `app/Services/CommunicationIntegrations/Telephony/Providers/WebitelTelephonyProvider.php`
- Modify: `app/Services/Company/Communication/Strategies/TelephonyCommunicationStrategy.php:30-56`
- Test: `tests/Feature/Services/Company/TelephonyCommunicationStrategyGuardTest.php`

**Interfaces:**
- Produces: `TelephonyProviderContract::requiresSubtype(): bool` — у Webitel `true`, у Infinity `false` (реализован в Task 6).

- [x] **Step 1: Написать падающий тест**

Создать `tests/Feature/Services/Company/TelephonyCommunicationStrategyGuardTest.php`:

```php
<?php

namespace Tests\Feature\Services\Company;

use App\Models\Collection\Company\Company;
use App\Models\Collection\Company\CompanyAction;
use App\Models\Collection\Company\CompanyLaunch;
use App\Models\Collection\Company\CompanySchedule;
use App\Models\User\User;
use App\Services\Company\Communication\Strategies\TelephonyCommunicationStrategy;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;

class TelephonyCommunicationStrategyGuardTest extends TestCase
{
    use DatabaseTransactions;

    protected $connectionsToTransact = ['collection', 'telephony'];

    private function launchWithoutQueue(): CompanyLaunch
    {
        $company = new Company();
        $company->name = 'Guard test company';
        $company->status = Company::STATUS_ACTIVE;
        $company->type = Company::TYPE_STATIC;
        $company->user_id = User::DEFAULT_TELEPHONY_USER_ID;
        $company->save();

        $action = CompanyAction::query()->where('code', CompanyAction::COMPANY_TELEPHONY)->firstOrFail();

        $schedule = new CompanySchedule();
        $schedule->company_id = $company->id;
        $schedule->action_id = $action->id;
        $schedule->weekdays = [];
        $schedule->hour = 0;
        $schedule->minute = 0;
        $schedule->additional_information = [];
        $schedule->save();

        $launch = new CompanyLaunch();
        $launch->company_id = $company->id;
        $launch->company_schedule_id = $schedule->id;
        $launch->status = CompanyLaunch::STATUS_PROCESSING;
        $launch->save();

        return $launch->fresh(['scheduleWithTrashed']);
    }

    public function test_marks_launch_failed_when_target_queue_is_missing(): void
    {
        $launch = $this->launchWithoutQueue();
        $action = CompanyAction::query()->where('code', CompanyAction::COMPANY_TELEPHONY)->firstOrFail();

        (new TelephonyCommunicationStrategy())->execute($launch, $action);

        $this->assertSame(CompanyLaunch::STATUS_FAILED, $launch->fresh()->status);
    }
}
```

- [x] **Step 2: Запустить тест, убедиться что падает**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=TelephonyCommunicationStrategyGuardTest`
Expected: FAIL — на не-перуанском инстансе ранний выход не срабатывает, статус остаётся `PROCESSING`. На перуанском инстансе тест пройдёт уже сейчас — тогда временно проверить логику, запустив его с `APP_INSTANCE=kazakhstan` (см. Task 9), либо принять как регрессионный.

- [x] **Step 3: Объявить `requiresSubtype()` в контракте**

В `TelephonyProviderContract`:

```php
public function requiresSubtype(): bool;
```

В `WebitelTelephonyProvider` добавить:

```php
/**
 * Подтип уходит в variables Webitel и обязателен для перуанского обзвона.
 */
public function requiresSubtype(): bool
{
    return true;
}
```

`InfinityTelephonyProvider` уже реализует метод (Task 6). Абстрактной реализации по умолчанию не добавлять — пусть каждый провайдер отвечает явно.

- [x] **Step 4: Переписать гвард стратегии**

В `TelephonyCommunicationStrategy::execute()` заменить

```php
if (isPeru() && (!$subtypeId || !$queueId)) {
    $this->updateLaunchStatus($companyLaunch, CompanyLaunch::STATUS_FAILED);
    return;
}
```

на

```php
$provider = resolve(AbstractTelephonyProvider::class);

if (!$queueId || ($provider->requiresSubtype() && !$subtypeId)) {
    $this->updateLaunchStatus($companyLaunch, CompanyLaunch::STATUS_FAILED);

    return;
}
```

Добавить импорт `use App\Services\CommunicationIntegrations\Telephony\Providers\Abstract\AbstractTelephonyProvider;`.

- [x] **Step 5: Запустить тест**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=TelephonyCommunicationStrategyGuardTest`
Expected: PASS, 1 тест.

- [x] **Step 6: Checkpoint**

```bash
docker exec -i polaris-service-new vendor/bin/pint \
  app/Services/Company/Communication/Strategies \
  app/Services/CommunicationIntegrations/Telephony/Providers \
  tests/Feature/Services/Company
```

Не коммитить.

---

### Task 8: UI расписания — строковый идентификатор цели и условный подтип

`ScheduleComponent::$queueForTelephony` объявлен как `?int` с правилом `integer` — UUID кампании в него не влезет. Плюс для Infinity подтип не нужен.

**Files:**
- Modify: `app/Livewire/Company/ScheduleComponent.php`
- Modify: `resources/views/livewire/company/schedule.blade.php:80-112`
- Test: `tests/Feature/Livewire/Company/ScheduleComponentTelephonyTest.php`

**Interfaces:**
- Consumes: `DialerQueueDirectoryContract` (Task 5), `AbstractTelephonyProvider::requiresSubtype()` (Task 7).
- Produces: `ScheduleComponent::$queueForTelephony` (`?string`), `ScheduleComponent::requiresSubtype(): bool`.

- [x] **Step 1: Написать падающий тест**

Создать `tests/Feature/Livewire/Company/ScheduleComponentTelephonyTest.php`:

```php
<?php

namespace Tests\Feature\Livewire\Company;

use App\Livewire\Company\ScheduleComponent;
use App\Models\Collection\Company\Company;
use App\Models\Collection\Company\CompanyAction;
use App\Models\Collection\Company\CompanySchedule;
use App\Models\User\User;
use App\Services\CommunicationIntegrations\Telephony\Contracts\DialerQueueDirectoryContract;
use App\Services\CommunicationIntegrations\Telephony\DTO\DialerQueueDTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Collection;
use Livewire\Livewire;
use Tests\TestCase;

class ScheduleComponentTelephonyTest extends TestCase
{
    use DatabaseTransactions;

    protected $connectionsToTransact = ['collection'];

    protected function setUp(): void
    {
        parent::setUp();

        $this->app->bind(DialerQueueDirectoryContract::class, function () {
            return new class implements DialerQueueDirectoryContract
            {
                public function queuesForCompanies(): Collection
                {
                    return collect([
                        new DialerQueueDTO('5a100e1f-7988-493f-98a1-7f4a026f8aec', '[КЦ] Заявки', 'predictive'),
                    ]);
                }
            };
        });
    }

    private function company(): Company
    {
        $company = new Company();
        $company->name = 'Schedule UI test';
        $company->status = Company::STATUS_ACTIVE;
        $company->type = Company::TYPE_STATIC;
        $company->user_id = User::DEFAULT_TELEPHONY_USER_ID;
        $company->save();

        return $company;
    }

    public function test_saves_uuid_target_id(): void
    {
        $company = $this->company();
        $action = CompanyAction::query()->where('code', CompanyAction::COMPANY_TELEPHONY)->firstOrFail();

        Livewire::test(ScheduleComponent::class, ['companyId' => $company->id, 'scheduleId' => null])
            ->set('actionId', $action->id)
            ->set('queueForTelephony', '5a100e1f-7988-493f-98a1-7f4a026f8aec')
            ->call('create')
            ->assertHasNoErrors();

        $schedule = CompanySchedule::query()->where('company_id', $company->id)->firstOrFail();

        $this->assertSame(
            '5a100e1f-7988-493f-98a1-7f4a026f8aec',
            $schedule->additional_information['queue_for_telephony']
        );
        $this->assertSame('[КЦ] Заявки', $schedule->additional_information['queue_for_telephony_name']);
    }

    public function test_requires_target_queue(): void
    {
        $company = $this->company();
        $action = CompanyAction::query()->where('code', CompanyAction::COMPANY_TELEPHONY)->firstOrFail();

        Livewire::test(ScheduleComponent::class, ['companyId' => $company->id, 'scheduleId' => null])
            ->set('actionId', $action->id)
            ->set('queueForTelephony', null)
            ->call('create')
            ->assertHasErrors('queueForTelephony');
    }
}
```

- [x] **Step 2: Запустить тест, убедиться что падает**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=ScheduleComponentTelephonyTest`
Expected: FAIL — свойство типизировано `?int`, UUID не присваивается.

- [x] **Step 3: Поменять тип свойства и правила**

В `ScheduleComponent`:

```php
public ?string $queueForTelephony = null;
```

В `rules()` заменить блок телефонии:

```php
if ($this->isTelephonyAction()) {
    $rules['queueForTelephony'] = ['required', 'string'];

    if ($this->requiresSubtype()) {
        $rules['subtypeScheduleId'] = ['required', 'integer'];
    }
}
```

В `messages()` заменить два сообщения по очереди:

```php
'queueForTelephony.required' => 'La cola es obligatoria.',
'queueForTelephony.string' => 'La cola debe ser un identificador válido.',
```

(правило `integer` для этого поля удалить).

- [x] **Step 4: Добавить `requiresSubtype()` и переключить справочник**

В `ScheduleComponent`:

```php
public function requiresSubtype(): bool
{
    return resolve(AbstractTelephonyProvider::class)->requiresSubtype();
}

public function getQueuesForTelephonyAction(): Collection
{
    if ($this->queuesForTelephony === null) {
        $this->queuesForTelephony = resolve(DialerQueueDirectoryContract::class)->queuesForCompanies();
    }

    return $this->queuesForTelephony;
}
```

Заменить импорт `use App\Integrations\Webitel\Services\WebitelQueueService;` на

```php
use App\Services\CommunicationIntegrations\Telephony\Contracts\DialerQueueDirectoryContract;
use App\Services\CommunicationIntegrations\Telephony\Providers\Abstract\AbstractTelephonyProvider;
use Illuminate\Support\Collection;
```

`WebitelCallService` из импортов не удалять, если он используется в других методах компонента — проверить.

- [x] **Step 5: Обновить blade**

В `resources/views/livewire/company/schedule.blade.php` обернуть селект подтипа так, чтобы для телефонии он показывался только когда нужен. Заменить строку

```blade
@elseif($this->isListAction() || $this->isTelephonyAction())
```

на

```blade
@elseif($this->isListAction() || $this->isTelephonyAction())
    @if($this->isListAction() || $this->requiresSubtype())
```

и закрыть добавленный `@if` сразу после закрывающего `</div>` блока подтипа (перед `@if($this->isTelephonyAction())` с выбором очереди):

```blade
    @endif
```

Метку `Cola` заменить на вызов языковой строки:

```blade
<label for="username" class="form-label">{{ __('views.company.schedule.telephony_target') }}</label>
```

Языковые файлы лежат в `resources/lang/<locale>/views.php`, секция `company.schedule` начинается на строке 742 файла `resources/lang/es/views.php`. Добавить ключ `'telephony_target' => 'Cola'` в испанский файл и `'telephony_target' => 'Кампания'` в русский (`resources/lang/ru/views.php`). Остальные локали не трогать — сработает фолбэк.

- [x] **Step 6: Запустить тест**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=ScheduleComponentTelephonyTest`
Expected: PASS, 2 теста.

- [x] **Step 7: Прогнать всю телефонию и компании**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter='Telephony|Webitel|Infinity|Schedule'`
Expected: PASS, все тесты.

- [x] **Step 8: Checkpoint**

```bash
docker exec -i polaris-service-new vendor/bin/pint \
  app/Livewire/Company/ScheduleComponent.php \
  tests/Feature/Livewire/Company
```

Не коммитить.

---

### Task 9: Проверка на казахстанском окружении

Кода эта задача не добавляет — она ловит то, что не поймают тесты: незакрытые миграции, пустой календарь, неверные переменные окружения.

**Files:**
- Проверка: `.env` (`APP_INSTANCE`, `INFINITY_BASE_URI`, `INFINITY_TOKEN`)
- Проверка: `database/migrations/2026_06_29_104303_add_telephony_company_action.php`, `2026_06_29_104304_change_company_schedule_subtypes_table.php`

- [ ] **Step 1: Убедиться, что миграции телефонии накатаны**

Run: `docker exec -i polaris-service-new php artisan migrate:status | grep -i 'telephony\|company_action\|subtypes'`
Expected: ни одной строки `Pending`. Если есть — `docker exec -i polaris-service-new php artisan migrate`.

- [ ] **Step 2: Проверить справочники в БД**

Run:

```bash
docker exec -i polaris-service-new php artisan tinker --execute="
  dump(\App\Models\Collection\Company\CompanyAction::query()->pluck('code','id')->toArray());
"
```

Expected: в списке присутствует `telephony`.

- [ ] **Step 3: Проверить, что справочник кампаний резолвится**

Run:

```bash
docker exec -i polaris-service-new php artisan tinker --execute="
  \$d = resolve(\App\Services\CommunicationIntegrations\Telephony\Contracts\DialerQueueDirectoryContract::class);
  dump(get_class(\$d), \$d->queuesForCompanies()->take(5)->map(fn(\$q) => [\$q->id, \$q->name, \$q->kind])->all());
"
```

Expected: на казахстанском инстансе — `InfinityCampaignDirectory` и непустой список кампаний без записей в состоянии `error`.

- [ ] **Step 4: Проверить календарь рабочих дней**

Run:

```bash
docker exec -i polaris-service-new php artisan tinker --execute="
  dump(\App\Models\Collection\Calendar::isTodayNonWorking());
"
```

Expected: `false` в рабочий день. Если `true` в обычный будний день — календарь заполнен неверно, и `TelephonyLaunchService::execute()` будет молча отклонять все запуски.

- [ ] **Step 5: Боевая проверка на одной записи**

Создать компанию со статическим списком из одного тестового документа, расписанием с действием «Телефония» и заведомо тестовой кампанией Infinity. Запустить `docker exec -i polaris-service-new php artisan company:scheduler`, затем проверить:

```bash
docker exec -i polaris-service-new php artisan tinker --execute="
  dump(\App\Models\Telephony\History::query()->latest('id')->take(3)->get(['id','document','telephone','status','communication_id'])->toArray());
"
```

Expected: запись со статусом `1` (SUCCEED) и заполненным `communication_id` в виде UUID.

- [ ] **Step 6: Финальный прогон тестов**

Run: `docker exec -i polaris-service-new php artisan test --compact`
Expected: PASS — включая ранее существовавшие тесты, которые не должны сломаться.

---

## Отступления от спеки

- **`processResponse()` остаётся в базовом классе.** Спека предлагала сделать разбор ответа абстрактным
  и унести в провайдеры. По факту Webitel и Infinity одинаково отдают признак успеха в поле `id`, поэтому
  общая реализация подходит обоим, а дубли в `WebitelTelephonyProvider` просто удаляются (Task 4).
  Если появится дайлер с другим форматом ответа — тогда и делать абстрактным.
- **`ApplicationSettings::$telephony_provider` и колонка `provider` в `telephony.buckets` не делаются** —
  по решению Q7 инстансы не пересекаются, провайдер однозначен по инстансу.
- **`config('polaris.telephony.enabled')` не чинится.** Ключа `polaris.telephony` не существует, поэтому
  защита от обзвона с тестового окружения в `TelephonyLaunchService::resume()` всегда пропускает вызов,
  а в `execute()` её нет вовсе. Задача сознательно оставлена за рамками — см. вопрос в конце.

## Риски и на что смотреть при ревью

- **Перуанский путь.** `WebitelTelephonyProviderTest` — единственная автоматическая защита того, что рефакторинг Tasks 1–4 не сломал боевой обзвон в Перу. Если тест пришлось ослабить, это повод остановиться.
- **`executeBatch()` берёт `targetId` из первого DTO пачки.** Сейчас все DTO одного запуска имеют одинаковую цель, поэтому это безопасно. Если появится смешанная пачка, потребуется группировка.
- **Тесты пишут в `collector_telephony` и `collector_drpeso` локальной БД.** `DatabaseTransactions` откатывает изменения, но при падении посреди теста мусор может остаться — проверить `telephony.histories` после первого запуска.
- **`Http::fake()` не перехватывает Guzzle.** Тесты Webitel используют `MockHandler`, тесты Infinity — `Http::fake()`. Не перепутать.
- **Тесты Tasks 7–8 идут на локальном перуанском инстансе.** `requiresSubtype()` там вернёт `true`, поэтому
  часть веток для Infinity автоматикой не покрывается. Реальная проверка — Task 9 на казахстанском окружении.

## Вопрос к согласованию

Чинить ли заодно **защиту от обзвона с тестового окружения**? Сейчас `TelephonyLaunchService::resume()`
проверяет `config('polaris.telephony.enabled', true)`, но ключа `polaris.telephony` в конфиге нет, поэтому
проверка всегда истинна; в `execute()` — том самом методе, через который идут запуски компаний, — её нет
вообще. То есть любой стенд с боевым токеном Infinity и накатанной компанией начнёт звонить реальным людям.
Это одна короткая задача: добавить ключ в `config/polaris.php`, применить проверку в `execute()`,
уведомлять в Telegram при отказе (механика уже написана в `resume()`).