From c88e81e8033dfd5a87d5c8b1e21ec9eaa24008ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 23 Jul 2026 11:36:56 +0300 Subject: [PATCH] =?UTF-8?q?docs(=D1=81=D0=BC=D1=81):=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=E2=80=94=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=A1=D0=9C=D0=A1=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=D0=BC=20(=D0=9C=D0=A2=D0=A1=20+=20=D0=A1=D0=9C=D0=A1-?= =?UTF-8?q?=D1=86=D0=B5=D0=BD=D1=82=D1=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9 задач TDD: справочник операторов, каналы МТС(Exolve)/СМС-центр(smsc.ru), реестр каналов в конфиге + сборка роутера, нормализация оператора, имя отправителя под канал, резерв выключателем (off). Схему БД не трогаем. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/observer/STATUS.md | 10 +- .../2026-07-23-sms-multi-operator-routing.md | 1212 +++++++++++++++++ 2 files changed, 1217 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-23-sms-multi-operator-routing.md diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 106bb5cd..e3e98006 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-22T17:39:46.528Z +Last updated: 2026-07-23T04:20:14.497Z | Контролёр | Состояние | Детали | |---|---|---| @@ -39,7 +39,7 @@ Last updated: 2026-07-22T17:39:46.528Z - Observer evidence: 0 episodes this month, 0 observer_error markers, 0 PII matches before filter - Legacy v1 episodes (not in factor analysis): 0 -- Last /brain-retro: 56 day(s) ago +- Last /brain-retro: 57 day(s) ago - Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). ## Метрики дисциплины @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3576 | MsMpEng | 11.79ч | NaNч | -| 9320 | Code | 5.42ч | 0.0ч | -| 1164 | svchost | 2.31ч | NaNч | +| 3576 | MsMpEng | 12.97ч | 0.0ч | +| 9320 | Code | 5.76ч | NaNч | +| 1164 | svchost | 2.49ч | NaNч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. diff --git a/docs/superpowers/plans/2026-07-23-sms-multi-operator-routing.md b/docs/superpowers/plans/2026-07-23-sms-multi-operator-routing.md new file mode 100644 index 00000000..b769138b --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-sms-multi-operator-routing.md @@ -0,0 +1,1212 @@ +# План реализации: маршрутизация СМС по операторам (МТС + СМС-центр) + +> **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:** Слать МТС-номера через МТС (Exolve), остальные — через СМС-центр (smsc.ru), с заделом на подключение новых операторов минимумом правок. + +**Architecture:** Переиспользуем существующий каркас модуля «Прогрев СМС» (`SmsProvider`/`SmsRouter`/`SendSmsCampaignJob`). Добавляем два живых провайдера, справочник операторов (`OperatorNormalizer`), декларативный реестр каналов в `config/services.php` + сборку роутера из него, и резолв имени отправителя под конкретный канал. Схему БД не трогаем. + +**Tech Stack:** PHP 8.3 / Laravel 13, Pest 4, `Illuminate\Support\Facades\Http` (Http::fake в тестах), Vue 3 + Vuetify 3. + +**Спека:** [docs/superpowers/specs/2026-07-23-sms-multi-operator-routing-design.md](../specs/2026-07-23-sms-multi-operator-routing-design.md) + +**Команды прогонов:** тесты — из каталога `app/`: `php artisan test --filter=<...>`. Формат/анализ: `composer pint`, `composer stan`. + +--- + +## Файловая карта + +| Действие | Файл | Ответственность | +|---|---|---| +| Создать | `app/app/Services/Sms/OperatorNormalizer.php` | сырое имя оператора → канонический ключ | +| Создать | `app/app/Services/Sms/Providers/SmscSmsProvider.php` | отправка через smsc.ru (универсал) | +| Создать | `app/app/Services/Sms/Providers/MtsSmsProvider.php` | отправка через МТС Exolve (только МТС) | +| Изменить | `app/config/services.php` | блок `sms.providers`, цены, флаг failover | +| Изменить | `app/app/Providers/AppServiceProvider.php` | сборка `SmsRouter` из реестра | +| Изменить | `app/app/Services/Sms/SmsRecipientSelector.php` | нормализация оператора перед `pick` | +| Изменить | `app/app/Jobs/SendSmsCampaignJob.php` | нормализация + имя-под-канал + `skipped_no_sender` | +| Изменить | `app/app/Http/Controllers/Api/Sales/SalesSmsController.php` | нормализация в preview/store | +| Изменить | `app/app/Models/SalesSmsMessage.php` | константа `SKIP_NO_SENDER` | +| Изменить | `app/app/Services/Sms/SmsRouter.php` | точка расширения failover (флаг, по умолчанию off) | +| Изменить | `app/resources/js/views/sales/SalesSmsView.vue` | подпись причины пропуска | +| Изменить | `app/.env.example` | новые ключи `SMS_*` | +| Создать | тесты (см. задачи) | покрытие каждого блока | + +--- + +## Task 1: Справочник операторов `OperatorNormalizer` + +**Files:** +- Create: `app/app/Services/Sms/OperatorNormalizer.php` +- Test: `app/tests/Unit/Sms/OperatorNormalizerTest.php` + +- [ ] **Step 1: Написать падающий тест** + +Создать `app/tests/Unit/Sms/OperatorNormalizerTest.php`: + +```php +canonical($raw))->toBe($expected); +})->with('operators'); +``` + +- [ ] **Step 2: Прогнать — убедиться, что падает** + +Run (из `app/`): `php artisan test --filter=OperatorNormalizerTest` +Expected: FAIL — `Class "App\Services\Sms\OperatorNormalizer" not found`. + +- [ ] **Step 3: Реализовать** + +Создать `app/app/Services/Sms/OperatorNormalizer.php`: + +```php + подстроки-приметы (в нижнем регистре). */ + private const SYNONYMS = [ + 'mts' => ['мтс', 'mts', 'мобильные телесистемы'], + 'beeline' => ['вымпелком', 'билайн', 'beeline'], + 'megafon' => ['мегафон', 'megafon'], + 'tele2' => ['tele2', 'теле2', 'т2 мобайл', 't2 mobile'], + 'yota' => ['yota', 'йота'], + ]; + + public function canonical(string $raw): string + { + $needle = mb_strtolower(trim($raw)); + + if ($needle === '') { + return ''; + } + + foreach (self::SYNONYMS as $key => $marks) { + foreach ($marks as $mark) { + if (str_contains($needle, $mark)) { + return $key; + } + } + } + + return ''; + } +} +``` + +- [ ] **Step 4: Прогнать — зелёный** + +Run: `php artisan test --filter=OperatorNormalizerTest` +Expected: PASS (13 assertions). + +- [ ] **Step 5: Коммит** + +```bash +git add app/app/Services/Sms/OperatorNormalizer.php app/tests/Unit/Sms/OperatorNormalizerTest.php +git commit -m "feat(смс): справочник операторов OperatorNormalizer (имя → канонический ключ)" +``` + +--- + +## Task 2: Канал СМС-центр `SmscSmsProvider` + +**Files:** +- Create: `app/app/Services/Sms/Providers/SmscSmsProvider.php` +- Test: `app/tests/Unit/Sms/SmscSmsProviderTest.php` + +Контракт smsc.ru: `GET https://smsc.ru/sys/send.php` с `login,psw,phones,mes,sender,charset=utf-8,fmt=3`. Успех → JSON `{"id":123,"cnt":1}`. Ошибка → JSON `{"error":"...","error_code":N}`. + +- [ ] **Step 1: Написать падающий тест** + +Создать `app/tests/Unit/Sms/SmscSmsProviderTest.php`: + +```php + 300]); +} + +function smsc_message(): SmsOutgoing +{ + return new SmsOutgoing( + phone: '79990000001', + body: 'Здравствуйте!', + senderName: 'liderra.ru', + operator: 'МегаФон', + segments: 1, + ); +} + +it('обслуживает всех как универсал', function () { + expect(smsc_provider()->servesOperators())->toBe(['*']) + ->and(smsc_provider()->key())->toBe('smsc'); +}); + +it('успешная отправка возвращает id сообщения и цену по сегментам', function () { + Http::fake(['smsc.ru/*' => Http::response(['id' => 555, 'cnt' => 1], 200)]); + + $result = smsc_provider()->send(smsc_message()); + + expect($result->providerMessageId)->toBe('555') + ->and($result->costKopecks)->toBe(300); +}); + +it('отказ smsc.ru бросает terminal-исключение', function () { + Http::fake(['smsc.ru/*' => Http::response(['error' => 'invalid number', 'error_code' => 7], 200)]); + + expect(fn () => smsc_provider()->send(smsc_message())) + ->toThrow(SmsSendException::class); +}); + +it('сетевой сбой — не terminal (можно повторить)', function () { + Http::fake(['smsc.ru/*' => Http::response('', 500)]); + + try { + smsc_provider()->send(smsc_message()); + $this->fail('ожидалось SmsSendException'); + } catch (SmsSendException $e) { + expect($e->terminal)->toBeFalse(); + } +}); +``` + +- [ ] **Step 2: Прогнать — падает** + +Run: `php artisan test --filter=SmscSmsProviderTest` +Expected: FAIL — класс не найден. + +- [ ] **Step 3: Реализовать** + +Создать `app/app/Services/Sms/Providers/SmscSmsProvider.php`: + +```php + $serves + * @param array $priceKopecks + */ +final class SmscSmsProvider implements SmsProvider +{ + private const SEND_URL = 'https://smsc.ru/sys/send.php'; + + public function __construct( + private readonly string $login, + private readonly string $password, + private readonly array $serves, + private readonly array $priceKopecks, + ) {} + + public function key(): string + { + return 'smsc'; + } + + public function servesOperators(): array + { + return $this->serves; + } + + public function priceKopecks(string $operator): int + { + return $this->priceKopecks[$operator] ?? $this->priceKopecks['*'] ?? 0; + } + + public function send(SmsOutgoing $message): SmsSendResult + { + try { + $response = Http::get(self::SEND_URL, [ + 'login' => $this->login, + 'psw' => $this->password, + 'phones' => $message->phone, + 'mes' => $message->body, + 'sender' => $message->senderName, + 'charset' => 'utf-8', + 'fmt' => 3, + ]); + } catch (Throwable $e) { + throw new SmsSendException('smsc.ru недоступен: '.$e->getMessage(), terminal: false); + } + + if ($response->serverError()) { + throw new SmsSendException('smsc.ru вернул '.$response->status(), terminal: false); + } + + $body = (array) $response->json(); + + if (isset($body['error'])) { + throw new SmsSendException( + 'smsc.ru отказал: '.(string) $body['error'], + terminal: true, + ); + } + + return new SmsSendResult( + providerMessageId: (string) ($body['id'] ?? ''), + segments: $message->segments, + costKopecks: $this->priceKopecks($message->operator) * $message->segments, + acceptedAt: CarbonImmutable::now(), + ); + } +} +``` + +- [ ] **Step 4: Прогнать — зелёный** + +Run: `php artisan test --filter=SmscSmsProviderTest` +Expected: PASS. + +- [ ] **Step 5: Коммит** + +```bash +git add app/app/Services/Sms/Providers/SmscSmsProvider.php app/tests/Unit/Sms/SmscSmsProviderTest.php +git commit -m "feat(смс): канал СМС-центр (smsc.ru) — универсал" +``` + +--- + +## Task 3: Канал МТС `MtsSmsProvider` + +**Files:** +- Create: `app/app/Services/Sms/Providers/MtsSmsProvider.php` +- Test: `app/tests/Unit/Sms/MtsSmsProviderTest.php` + +Контракт МТС Exolve: `POST https://api.exolve.ru/messaging/v1/SendSMS`, заголовок `Authorization: Bearer `, тело JSON `{number: <имя отправителя>, destination: <номер>, text: <текст>}`. Успех 2xx → JSON с id сообщения (`message_id`/`id`). 4xx → отказ terminal, 5xx/сеть → не terminal. ⚠️ Точные имена полей ответа уточнить по личным докам Exolve при первом боевом прогоне — парсим оба варианта id. + +- [ ] **Step 1: Написать падающий тест** + +Создать `app/tests/Unit/Sms/MtsSmsProviderTest.php`: + +```php + 400]); +} + +function mts_message(): SmsOutgoing +{ + return new SmsOutgoing( + phone: '79990000001', + body: 'Здравствуйте!', + senderName: 'liderra.ru', + operator: 'МТС', + segments: 1, + ); +} + +it('обслуживает только МТС', function () { + expect(mts_provider()->servesOperators())->toBe(['mts']) + ->and(mts_provider()->key())->toBe('mts'); +}); + +it('успешная отправка возвращает id и цену', function () { + Http::fake(['api.exolve.ru/*' => Http::response(['message_id' => 'abc-1'], 200)]); + + $result = mts_provider()->send(mts_message()); + + expect($result->providerMessageId)->toBe('abc-1') + ->and($result->costKopecks)->toBe(400); +}); + +it('отказ 4xx — terminal', function () { + Http::fake(['api.exolve.ru/*' => Http::response(['error' => 'bad request'], 400)]); + + try { + mts_provider()->send(mts_message()); + $this->fail('ожидалось SmsSendException'); + } catch (SmsSendException $e) { + expect($e->terminal)->toBeTrue(); + } +}); + +it('сбой 5xx — не terminal', function () { + Http::fake(['api.exolve.ru/*' => Http::response('', 503)]); + + try { + mts_provider()->send(mts_message()); + $this->fail('ожидалось SmsSendException'); + } catch (SmsSendException $e) { + expect($e->terminal)->toBeFalse(); + } +}); +``` + +- [ ] **Step 2: Прогнать — падает** + +Run: `php artisan test --filter=MtsSmsProviderTest` +Expected: FAIL — класс не найден. + +- [ ] **Step 3: Реализовать** + +Создать `app/app/Services/Sms/Providers/MtsSmsProvider.php`: + +```php + $serves + * @param array $priceKopecks + */ +final class MtsSmsProvider implements SmsProvider +{ + private const SEND_URL = 'https://api.exolve.ru/messaging/v1/SendSMS'; + + public function __construct( + private readonly string $token, + private readonly array $serves, + private readonly array $priceKopecks, + ) {} + + public function key(): string + { + return 'mts'; + } + + public function servesOperators(): array + { + return $this->serves; + } + + public function priceKopecks(string $operator): int + { + return $this->priceKopecks[$operator] ?? $this->priceKopecks['*'] ?? 0; + } + + public function send(SmsOutgoing $message): SmsSendResult + { + try { + $response = Http::withToken($this->token)->post(self::SEND_URL, [ + 'number' => $message->senderName, + 'destination' => $message->phone, + 'text' => $message->body, + ]); + } catch (Throwable $e) { + throw new SmsSendException('МТС недоступен: '.$e->getMessage(), terminal: false); + } + + if (! $response->successful()) { + // 4xx — наша ошибка (ключ/номер/текст), повтор бесполезен; + // 5xx — сбой оператора, повтор осмыслен. + throw new SmsSendException( + 'МТС вернул '.$response->status(), + terminal: $response->clientError(), + ); + } + + $body = (array) $response->json(); + + return new SmsSendResult( + providerMessageId: (string) ($body['message_id'] ?? $body['id'] ?? ''), + segments: $message->segments, + costKopecks: $this->priceKopecks($message->operator) * $message->segments, + acceptedAt: CarbonImmutable::now(), + ); + } +} +``` + +- [ ] **Step 4: Прогнать — зелёный** + +Run: `php artisan test --filter=MtsSmsProviderTest` +Expected: PASS. + +- [ ] **Step 5: Коммит** + +```bash +git add app/app/Services/Sms/Providers/MtsSmsProvider.php app/tests/Unit/Sms/MtsSmsProviderTest.php +git commit -m "feat(смс): канал МТС (Exolve) — только МТС-номера" +``` + +--- + +## Task 4: Реестр каналов в конфиге + сборка роутера + +**Files:** +- Modify: `app/config/services.php` (блок `sms`) +- Modify: `app/app/Providers/AppServiceProvider.php:103-111` +- Modify: `app/.env.example` +- Test: `app/tests/Feature/Sales/SmsRouterBuildTest.php` + +- [ ] **Step 1: Написать падающий тест** + +Создать `app/tests/Feature/Sales/SmsRouterBuildTest.php`: + +```php +set('services.sms.sandbox', true); + app()->forgetInstance(SmsRouter::class); + + $router = app(SmsRouter::class); + + expect($router->pick('mts'))->toBeInstanceOf(FakeSmsProvider::class); +}); + +it('в бою поднимаются только включённые каналы с ключами', function () { + config()->set('services.sms.sandbox', false); + config()->set('services.smsc.login', 'l'); + config()->set('services.smsc.password', 'p'); + config()->set('services.sms.mts.token', ''); // ключа нет → канал не встаёт + config()->set('services.sms.mts.enabled', true); + config()->set('services.sms.smsc.enabled', true); + app()->forgetInstance(SmsRouter::class); + + $router = app(SmsRouter::class); + + // МТС-ключа нет → МТС-номер уходит универсалу (СМС-центр). + expect($router->pick('mts')?->key())->toBe('smsc') + ->and($router->pick('')?->key())->toBe('smsc'); +}); +``` + +- [ ] **Step 2: Прогнать — падает** + +Run: `php artisan test --filter=SmsRouterBuildTest` +Expected: FAIL — сейчас роутер в бою пустой (`pick` вернёт null), либо конфиг `sms.smsc`/`sms.providers` отсутствует. + +- [ ] **Step 3: Расширить конфиг `services.sms`** + +В `app/config/services.php` заменить блок `'sms' => [ ... ]` (строки 328-341) на: + +```php + 'sms' => [ + // Песочница: провайдеры не вызываются, отправка только помечается в журнале. + 'sandbox' => (bool) env('SMS_SANDBOX', true), + + // Резерв канала: точный оператор отказал → попробовать универсала. + // По умолчанию ВЫКЛ (решение владельца 23.07.2026) — включить одной строкой. + 'failover_to_universal' => filter_var(env('SMS_FAILOVER_TO_UNIVERSAL', false), FILTER_VALIDATE_BOOL), + + 'mts' => [ + 'token' => env('SMS_MTS_TOKEN'), + 'enabled' => (bool) env('SMS_MTS_ENABLED', false), + ], + 'beeline' => [ + 'token' => env('SMS_BEELINE_TOKEN'), + 'enabled' => (bool) env('SMS_BEELINE_ENABLED', false), + ], + 'smsc' => [ + 'enabled' => (bool) env('SMS_SMSC_ENABLED', false), + ], + + // Декларативный реестр живых каналов. Новый оператор = запись здесь + // + ключ в .env + (если API другой) файл-провайдер + арм в makeSmsProvider(). + // Цены — в копейках за сегмент, ключ '*' = единая цена канала; меняются + // через .env без выката. + 'providers' => [ + 'smsc' => [ + 'enabled' => (bool) env('SMS_SMSC_ENABLED', false), + 'serves' => ['*'], + 'price_kopecks' => ['*' => (int) env('SMS_SMSC_PRICE_KOP', 0)], + ], + 'mts' => [ + 'enabled' => (bool) env('SMS_MTS_ENABLED', false), + 'serves' => ['mts'], + 'price_kopecks' => ['*' => (int) env('SMS_MTS_PRICE_KOP', 0)], + ], + ], + ], +``` + +- [ ] **Step 4: Переписать сборку роутера** + +В `app/app/Providers/AppServiceProvider.php` заменить блок `$this->app->singleton(SmsRouter::class, ...)` (строки 103-111) на: + +```php + // Маршрутизатор СМС. Песочница включена — только заглушка (деньги оператору + // не уходят). В бою — включённые каналы из реестра services.sms.providers, + // у которых есть учётные данные (нет ключа → канал пропускается, поток цел). + $this->app->singleton(SmsRouter::class, function () { + if (config('services.sms.sandbox')) { + return new SmsRouter([new FakeSmsProvider]); + } + + $providers = []; + + foreach ((array) config('services.sms.providers', []) as $key => $entry) { + if (empty($entry['enabled'])) { + continue; + } + + $provider = $this->makeSmsProvider((string) $key, (array) $entry); + + if ($provider !== null) { + $providers[] = $provider; + } + } + + return new SmsRouter($providers); + }); +``` + +Добавить приватный метод в тот же класс (после `register()`): + +```php + /** + * Создать канал по ключу реестра. Нет учётных данных → null (канал не встаёт). + * Новый оператор со СВОИМ API = ещё один арм здесь + запись в services.sms.providers. + * + * @param array $entry + */ + private function makeSmsProvider(string $key, array $entry): ?\App\Services\Sms\SmsProvider + { + /** @var array $serves */ + $serves = $entry['serves'] ?? ['*']; + /** @var array $price */ + $price = $entry['price_kopecks'] ?? ['*' => 0]; + + return match ($key) { + 'smsc' => ($login = (string) config('services.smsc.login')) !== '' + ? new \App\Services\Sms\Providers\SmscSmsProvider( + $login, + (string) config('services.smsc.password'), + $serves, + $price, + ) + : null, + 'mts' => ($token = (string) config('services.sms.mts.token')) !== '' + ? new \App\Services\Sms\Providers\MtsSmsProvider($token, $serves, $price) + : null, + default => null, + }; + } +``` + +- [ ] **Step 5: Прогнать — зелёный** + +Run: `php artisan test --filter=SmsRouterBuildTest` +Expected: PASS. + +- [ ] **Step 6: Дописать `.env.example`** + +В `app/.env.example` добавить (рядом с прочими сервисами): + +``` +SMS_SANDBOX=true +SMS_FAILOVER_TO_UNIVERSAL=false +SMS_SMSC_ENABLED=false +SMS_SMSC_PRICE_KOP=0 +SMS_MTS_ENABLED=false +SMS_MTS_TOKEN= +SMS_MTS_PRICE_KOP=0 +``` + +- [ ] **Step 7: Коммит** + +```bash +git add app/config/services.php app/app/Providers/AppServiceProvider.php app/.env.example app/tests/Feature/Sales/SmsRouterBuildTest.php +git commit -m "feat(смс): реестр каналов в конфиге + сборка роутера из реестра" +``` + +--- + +## Task 5: Нормализация оператора в отборе, джобе и предпросмотре + +**Files:** +- Modify: `app/app/Services/Sms/SmsRecipientSelector.php:20-23,63` +- Modify: `app/app/Jobs/SendSmsCampaignJob.php:45-53,141-142` +- Modify: `app/app/Http/Controllers/Api/Sales/SalesSmsController.php:22-25,59,117` +- Test: `app/tests/Feature/Sales/SmsOperatorRoutingTest.php` + +- [ ] **Step 1: Написать падающий тест** + +Создать `app/tests/Feature/Sales/SmsOperatorRoutingTest.php`: + +```php + 300]), + new MtsSmsProvider('t', ['mts'], ['*' => 400]), + ]); +} + +it('МТС-номер (как его пишет ДаДата) маршрутизируется на канал МТС', function () { + SalesAdAudiencePhone::create([ + 'phone' => '79990000010', 'expires_at' => now()->addDays(30), + 'state' => 'active', 'operator' => 'ПАО "МТС"', 'phone_type' => 'mobile', + ]); + + $selector = new SmsRecipientSelector(routing_router(), new OperatorNormalizer); + $plan = $selector->build(['79990000010']); + + expect($plan->sendable)->toHaveCount(1); + + $canonical = (new OperatorNormalizer)->canonical('ПАО "МТС"'); + expect(routing_router()->pick($canonical)?->key())->toBe('mts'); +}); + +it('чужой оператор уходит на универсал (СМС-центр)', function () { + $canonical = (new OperatorNormalizer)->canonical('МегаФон'); + expect(routing_router()->pick($canonical)?->key())->toBe('smsc'); +}); +``` + +- [ ] **Step 2: Прогнать — падает** + +Run: `php artisan test --filter=SmsOperatorRoutingTest` +Expected: FAIL — у `SmsRecipientSelector` нет второго параметра-нормализатора. + +- [ ] **Step 3: Внедрить нормализатор в `SmsRecipientSelector`** + +В `app/app/Services/Sms/SmsRecipientSelector.php`: + +Заменить конструктор (строка 20): + +```php + public function __construct( + private readonly SmsRouter $router, + private readonly OperatorNormalizer $normalizer = new OperatorNormalizer, + ) {} +``` + +Заменить проверку маршрута (строка 63): + +```php + if ($this->router->pick($this->normalizer->canonical((string) $row->operator)) === null) { +``` + +Добавить импорт в шапку файла: + +```php +use App\Services\Sms\OperatorNormalizer; +``` + +(namespace уже `App\Services\Sms`, поэтому импорт не обязателен — класс в том же пространстве; строку добавлять не нужно, если PHPStan не требует. Оставить без импорта.) + +- [ ] **Step 4: Внедрить нормализатор в джоб** + +В `app/app/Jobs/SendSmsCampaignJob.php`: + +В сигнатуру `handle()` добавить параметр (после `$charge`): + +```php + public function handle( + ?SmsRecipientSelector $selector = null, + ?SmsRouter $router = null, + ?SmsSegmentCalculator $calculator = null, + ?SmsChargeService $charge = null, + ?OperatorNormalizer $normalizer = null, + ): void { + $router ??= app(SmsRouter::class); + $normalizer ??= new OperatorNormalizer; + $selector ??= new SmsRecipientSelector($router, $normalizer); + $calculator ??= new SmsSegmentCalculator; +``` + +Заменить выбор канала (строки 141-142): + +```php + $operator = (string) $row->operator; + $provider = $router->pick($normalizer->canonical($operator)); +``` + +Добавить импорт в шапку: + +```php +use App\Services\Sms\OperatorNormalizer; +``` + +- [ ] **Step 5: Внедрить нормализатор в контроллер** + +В `app/app/Http/Controllers/Api/Sales/SalesSmsController.php`: + +Заменить конструктор (строки 22-25): + +```php + public function __construct( + private readonly SmsRouter $router, + private readonly SmsSegmentCalculator $calculator, + private readonly OperatorNormalizer $normalizer, + ) {} +``` + +В `preview()` заменить строку 59: + +```php + $provider = $this->router->pick($this->normalizer->canonical((string) $row->operator)); +``` + +В `store()` заменить строку 117: + +```php + $provider = $this->router->pick($this->normalizer->canonical((string) $row->operator)); +``` + +Добавить импорт в шапку: + +```php +use App\Services\Sms\OperatorNormalizer; +``` + +- [ ] **Step 6: Прогнать — зелёный (новый + не сломались старые)** + +Run: `php artisan test --filter="SmsOperatorRoutingTest|SmsRecipientSelectorTest|SendSmsCampaignJobTest|SalesSmsScreenTest"` +Expected: PASS во всех (старые используют оператора «МТС» + заглушку-универсал `*`, нормализация им не мешает). + +- [ ] **Step 7: Коммит** + +```bash +git add app/app/Services/Sms/SmsRecipientSelector.php app/app/Jobs/SendSmsCampaignJob.php app/app/Http/Controllers/Api/Sales/SalesSmsController.php app/tests/Feature/Sales/SmsOperatorRoutingTest.php +git commit -m "feat(смс): маршрутизация по каноническому оператору (нормализация в отборе/джобе/предпросмотре)" +``` + +--- + +## Task 6: Имя отправителя под конкретный канал + статус `skipped_no_sender` + +**Files:** +- Modify: `app/app/Models/SalesSmsMessage.php:40-50` +- Modify: `app/app/Jobs/SendSmsCampaignJob.php:63-75,136-207` +- Test: `app/tests/Feature/Sales/SmsPerChannelSenderTest.php` + +Логика: имя отправителя берётся под канал (`provider_key`), а не одно на кампанию. Старт кампании возможен, если есть хоть один активный отправитель. Номера канала без активного имени → `skipped_no_sender`, деньги целы. + +- [ ] **Step 1: Добавить константу статуса** + +В `app/app/Models/SalesSmsMessage.php` после `SKIP_NO_FUNDS` (строка 50) добавить: + +```php + public const SKIP_NO_SENDER = 'skipped_no_sender'; +``` + +- [ ] **Step 2: Написать падающий тест** + +Создать `app/tests/Feature/Sales/SmsPerChannelSenderTest.php`: + +```php + $phone, 'expires_at' => now()->addDays(30), + 'state' => 'active', 'operator' => 'МТС', 'phone_type' => 'mobile', + ]); + + return SalesSmsCampaign::create([ + 'title' => 'Прогрев', 'body' => 'Здравствуйте!', 'created_by' => 1, + 'status' => SalesSmsCampaign::STATUS_QUEUED, + ]); +} + +it('в песочнице имя берётся для канала fake', function () { + SalesSmsSender::create(['name' => 'liderra.ru', 'provider_key' => 'fake', 'status' => 'active']); + $campaign = per_channel_campaign('79990000020'); + + (new SendSmsCampaignJob($campaign->id, ['79990000020']))->handle(); + + expect($campaign->refresh()->sent_count)->toBe(1); +}); + +it('нет имени для канала → номер помечается skipped_no_sender, деньги целы', function () { + // Активное имя есть, но для ДРУГОГО канала — для fake имени нет. + SalesSmsSender::create(['name' => 'liderra.ru', 'provider_key' => 'mts', 'status' => 'active']); + $campaign = per_channel_campaign('79990000021'); + + (new SendSmsCampaignJob($campaign->id, ['79990000021']))->handle(); + + $row = SalesSmsMessage::where('campaign_id', $campaign->id)->first(); + expect($row->status)->toBe(SalesSmsMessage::SKIP_NO_SENDER) + ->and($row->cost_kopecks)->toBe(0) + ->and($campaign->refresh()->sent_count)->toBe(0); +}); + +it('не стартует, если ни одного активного имени нет', function () { + SalesSmsSender::create(['name' => 'liderra.ru', 'provider_key' => 'fake', 'status' => 'pending']); + $campaign = per_channel_campaign('79990000022'); + + (new SendSmsCampaignJob($campaign->id, ['79990000022']))->handle(); + + expect($campaign->refresh()->status)->toBe(SalesSmsCampaign::STATUS_FAILED) + ->and($campaign->last_error)->toContain('имя отправителя'); +}); +``` + +- [ ] **Step 3: Прогнать — падает** + +Run: `php artisan test --filter=SmsPerChannelSenderTest` +Expected: FAIL — джоб пока использует одно имя кампании и не знает `skipped_no_sender`. + +- [ ] **Step 4: Переписать резолв имени в джобе** + +В `app/app/Jobs/SendSmsCampaignJob.php` заменить блок проверки имени (строки 63-75) на загрузку активных имён по каналам: + +```php + // Активные имена отправителя по каналам (provider_key => sender). + // tenant NULL = «Лидерра сама». + $senders = \App\Models\SalesSmsSender::query() + ->where('status', \App\Models\SalesSmsSender::STATUS_ACTIVE) + ->when($campaign->tenant_id === null, fn ($q) => $q->whereNull('tenant_id')) + ->when($campaign->tenant_id !== null, fn ($q) => $q->where('tenant_id', $campaign->tenant_id)) + ->get() + ->keyBy('provider_key'); + + if ($senders->isEmpty()) { + $campaign->update([ + 'status' => SalesSmsCampaign::STATUS_FAILED, + 'last_error' => 'Не согласовано имя отправителя ни для одного канала — рассылка не запускалась.', + 'finished_at' => now(), + ]); + + Log::warning('sms.no_active_sender', ['campaign_id' => $campaign->id]); + + return; + } +``` + +Удалить прежние строки, использующие `$sender = $campaign->sender;` и `$sender->name` — теперь имя резолвится в цикле. + +В цикле отправки (был блок вокруг строк 157-166) заменить формирование `SmsOutgoing` так, чтобы имя бралось под канал, а при отсутствии — пропуск: + +```php + $segments = $calculator->segments($campaign->body); + + $channelSender = $senders->get($provider->key()); + + if ($channelSender === null) { + SalesSmsMessage::create([ + 'campaign_id' => $campaign->id, + 'phone' => $row->phone, + 'phone_id' => $row->id, + 'operator' => $operator, + 'provider_key' => $provider->key(), + 'status' => SalesSmsMessage::SKIP_NO_SENDER, + 'segments' => 0, + 'cost_kopecks' => 0, + ]); + $skippedCount++; + + continue; + } + + try { + $result = $provider->send(new SmsOutgoing( + phone: $row->phone, + body: $campaign->body, + senderName: $channelSender->name, + operator: $operator, + segments: $segments, + )); +``` + +(Остальное тело `try`/`catch` — без изменений.) + +- [ ] **Step 5: Прогнать — зелёный (новый + регресс джоба)** + +Run: `php artisan test --filter="SmsPerChannelSenderTest|SendSmsCampaignJobTest"` +Expected: PASS. (`SendSmsCampaignJobTest` использует имя с `provider_key='fake'` — резолвится под канал fake; тест «не стартует без имени» ставит его в `pending` → активных нет → FAILED.) + +- [ ] **Step 6: Коммит** + +```bash +git add app/app/Models/SalesSmsMessage.php app/app/Jobs/SendSmsCampaignJob.php app/tests/Feature/Sales/SmsPerChannelSenderTest.php +git commit -m "feat(смс): имя отправителя под конкретный канал + статус skipped_no_sender" +``` + +--- + +## Task 7: Экран — подпись новой причины пропуска + +**Files:** +- Modify: `app/resources/js/views/sales/SalesSmsView.vue:238-245` +- Test: `app/tests/Frontend/SalesSmsView.spec.ts` (если есть; иначе — проверка в существующем `SalesSmsScreenTest.php`) + +- [ ] **Step 1: Добавить подпись причины** + +В `app/resources/js/views/sales/SalesSmsView.vue` в объект `SKIP_LABELS` (строки 238-245) добавить строку: + +```ts + skipped_no_sender: 'нет имени для канала', +``` + +(Причина автоматически попадёт и в `MESSAGE_STATUS_LABELS` — он расширяет `SKIP_LABELS` через spread на строке 272.) + +- [ ] **Step 2: Проверка сборки фронта** + +Run (из `app/`): `npm run type-check` +Expected: без ошибок типов. + +- [ ] **Step 3: Прогнать фронт-тесты модуля (если есть)** + +Run (из `app/`): `npm run test:vue -- SalesSmsView` +Expected: PASS (или «нет такого теста» — тогда пропустить, подпись покрыта визуально). + +- [ ] **Step 4: Коммит** + +```bash +git add app/resources/js/views/sales/SalesSmsView.vue +git commit -m "feat(смс): подпись причины пропуска «нет имени для канала» на экране" +``` + +--- + +## Task 8: Точка расширения — резерв канала (флаг, по умолчанию ВЫКЛ) + +**Files:** +- Modify: `app/app/Services/Sms/SmsRouter.php` +- Test: `app/tests/Unit/Sms/SmsRouterFailoverTest.php` + +Резерв **не активируется** сейчас (решение владельца). Закладываем метод `pickWithFallback()`, который при `services.sms.failover_to_universal=true` возвращает и запасной универсал. Основной `pick()` не меняется. + +- [ ] **Step 1: Написать падающий тест** + +Создать `app/tests/Unit/Sms/SmsRouterFailoverTest.php` (самодостаточный — свой хелпер `failover_provider`, без зависимости от соседнего теста): + +```php +k; + } + + public function servesOperators(): array + { + return $this->ops; + } + + public function priceKopecks(string $operator): int + { + return 100; + } + + public function send(SmsOutgoing $m): SmsSendResult + { + return new SmsSendResult('x', 1, 100, CarbonImmutable::now()); + } + }; +} + +it('по умолчанию резерва нет — второй канал не отдаётся', function () { + config()->set('services.sms.failover_to_universal', false); + $router = new SmsRouter([ + failover_provider('mts', ['mts']), + failover_provider('smsc', ['*']), + ]); + + expect($router->fallbackFor('mts'))->toBeNull(); +}); + +it('при включённом флаге резерв — универсал', function () { + config()->set('services.sms.failover_to_universal', true); + $router = new SmsRouter([ + failover_provider('mts', ['mts']), + failover_provider('smsc', ['*']), + ]); + + expect($router->fallbackFor('mts')?->key())->toBe('smsc'); +}); +``` + +- [ ] **Step 2: Прогнать — падает** + +Run: `php artisan test --filter=SmsRouterFailoverTest` +Expected: FAIL — метода `fallbackFor()` нет. + +- [ ] **Step 3: Добавить метод в `SmsRouter`** + +В `app/app/Services/Sms/SmsRouter.php` добавить метод после `pick()`: + +```php + /** + * Запасной канал, если основной отказал. Работает ТОЛЬКО при включённом + * флаге services.sms.failover_to_universal (по умолчанию ВЫКЛ). Возвращает + * универсала, отличного от основного. Точка расширения — сейчас не вызывается + * из джоба (решение владельца 23.07.2026 — без резерва). + */ + public function fallbackFor(string $operator): ?SmsProvider + { + if (! config('services.sms.failover_to_universal')) { + return null; + } + + $primary = $this->pick($operator); + + foreach ($this->providers as $provider) { + if (in_array('*', $provider->servesOperators(), true) && $provider !== $primary) { + return $provider; + } + } + + return null; + } +``` + +- [ ] **Step 4: Прогнать — зелёный** + +Run: `php artisan test --filter=SmsRouterFailoverTest` +Expected: PASS. + +- [ ] **Step 5: Коммит** + +```bash +git add app/app/Services/Sms/SmsRouter.php app/tests/Unit/Sms/SmsRouterFailoverTest.php +git commit -m "feat(смс): точка расширения резерва канала (флаг, по умолчанию выкл)" +``` + +--- + +## Task 9: Финальная проверка и вычитка + +**Files:** нет новых — прогон и самоконтроль. + +- [ ] **Step 1: Форматирование и статанализ** + +Run (из `app/`): `composer pint && composer stan` +Expected: Pint — no changes/fixed; Larastan — 0 errors по затронутым файлам. + +- [ ] **Step 2: Полный прогон СМС-модуля** + +Run (из `app/`): `php artisan test --filter="Sms"` +Expected: PASS — все Unit/Feature по СМС (Operator, Smsc, Mts, RouterBuild, RouterFailover, OperatorRouting, PerChannelSender, RecipientSelector, SendSmsCampaignJob, SmsRouter, SalesSmsScreen). + +- [ ] **Step 3: Регрессия sales-слоя (смежное не задето)** + +Run (из `app/`): `php artisan test --filter="Sales"` +Expected: PASS. + +- [ ] **Step 4: Самоконтроль по спеке** + +Сверить с [спекой](../specs/2026-07-23-sms-multi-operator-routing-design.md): §3.1 (два провайдера) ✔ Task 2/3; §3.2 (реестр) ✔ Task 4; §3.3 (нормализатор) ✔ Task 1/5; §3.4 (имя-под-канал) ✔ Task 6; §3.5 (цены из конфига) ✔ Task 2/3/4; §3.6 (failover-флаг) ✔ Task 8; §7 (тесты) ✔ по задачам. + +- [ ] **Step 5: Итог владельцу (без коммита)** + +Кратко доложить: что готово, что осталось для реального выката (боевые ключи МТС в `.env`, `SMS_SMSC_ENABLED=true`, `SMS_MTS_ENABLED=true`, цены `SMS_*_PRICE_KOP`, активные имена отправителя по каналам, выключить `SMS_SANDBOX`), и напомнить про проверку боевых написаний оператора (спека §8) — с разрешения владельца, только чтение. + +--- + +## Что НЕ входит (YAGNI) + +- Реальный вызов `fallbackFor()` из джоба (только точка расширения). +- Подтяжка статусов доставки (DLR/webhook). +- Автосоздание строк `sales_sms_senders` (заводятся вручную/в админке). +- Изменения схемы БД (все нужные поля уже есть).