From 6e771636ef04bc20b3f9d0fd8df16bc89a559c99 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: Tue, 21 Jul 2026 06:35:43 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D1=81=D0=BC=D1=81):=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=20=C2=AB=D0=9F=D1=80=D0=BE=D0=B3=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=20=D0=A1=D0=9C=D0=A1=C2=BB=20=D1=81=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=BE=D0=BC=20=D0=BF=D0=BE=D0=B4=20=D0=BC=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D0=B8=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Начальник отдела продаж отмечает фирмы прогрева галочками, пишет текст, видит цену ДО отправки и журнал после. Отправки СМС в проекте не было вообще — ни у СМС-центра (только баланс и HLR), ни у МТС (только файл). Что сделано: - разъём провайдера SmsProvider: новый оператор подключается одним файлом - заглушка FakeSmsProvider — модуль работает и проверяется ДО согласования имени отправителя у операторов (это недели), иначе разработку не закончить - маршрутизация по оператору: билайновский номер уходит через Билайн за 4,75 ₽, прочие через МТС — без ручного выбора канала - стоп-лист: кто отписался, тому не шлём никогда, проверка перед списанием - отбор получателей с шестью причинами пропуска, все ДО траты денег - списание скопировано с AutopodborChargeService; пока клиента нет (tenant_id пуст) с баланса не берём — платим оператору напрямую - оператор номера доезжает из «Поиска клиентов» в прогрев (был известен и оплачен ДаДате, но терялся при передаче) Мультиклиентность в костях: колонка tenant_id во всех четырёх таблицах СМС с первого дня, NULL = «Лидерра сама». Клиент добавляется строкой, а не переделкой модуля. Найдено и закрыто при исполнении: - замок от двойного списания стоял не на том соединении: кампания на pgsql_supplier, деньги на pgsql, lockForUpdate по кампании отпускался сразу. На бою два запуска списали бы дважды, обрыв — оставил бы пометку «оплачено» при неушедших деньгах. Источник правды перенесён в balance_transactions под замок по тенанту. Доказано тестом: старый код списывал 700 вместо 850 - приём в портал требовал phones строкой по regex — словарь с оператором получал 422, в базу не доезжало ничего. Тесты были зелёные, потому что звали сервис МИМО контроллера. Проверка теперь принимает оба формата, тест идёт через HTTP - телефоны директоров в contacts остаются строками (договор SalesProspectController), словари — только в верхнем phones Заодно вылечена мигающая поломка 48 тестов доставки лидов: помощник createRoutingSnapshotFromProject клал снимок на сегодня, а LeadRouter после 21:00 МСК ищет завтрашний (вечерний переворот заливки) — вечерние прогоны падали, дневные проходили. Помощник теперь зеркалит активную дату роутера в любой час. Регрессия SnapshotHelperTimeOfDayTest замораживает 22:00 МСК и пинит инвариант. Боевой LeadRouter не тронут. Тесты: 84 бэкенд + фронт по экрану + 397 поисковика, весь набор 3226 зелёный, статанализ чист. Все защиты проверены вырезанием. План: docs/superpowers/plans/2026-07-20-sms-progrev-modul.md Спека: docs/superpowers/specs/2026-07-20-sms-progrev-modul-design.md Co-Authored-By: Claude Opus 4.8 --- .../Api/Sales/SalesIntegrationController.php | 19 +- .../Api/Sales/SalesSmsController.php | 158 + app/app/Jobs/SendSmsCampaignJob.php | 237 ++ app/app/Models/BalanceTransaction.php | 2 + app/app/Models/SalesAdAudiencePhone.php | 3 + app/app/Models/SalesSmsCampaign.php | 73 + app/app/Models/SalesSmsMessage.php | 74 + app/app/Models/SalesSmsOptout.php | 31 + app/app/Models/SalesSmsSender.php | 48 + app/app/Providers/AppServiceProvider.php | 15 + app/app/Services/Sales/AdAudienceIntake.php | 17 +- .../Sms/Providers/FakeSmsProvider.php | 54 + app/app/Services/Sms/SmsChargeService.php | 108 + app/app/Services/Sms/SmsOutgoing.php | 17 + app/app/Services/Sms/SmsPlan.php | 23 + app/app/Services/Sms/SmsProvider.php | 28 + app/app/Services/Sms/SmsRecipientSelector.php | 74 + app/app/Services/Sms/SmsRouter.php | 45 + app/app/Services/Sms/SmsSegmentCalculator.php | 36 + app/app/Services/Sms/SmsSendException.php | 19 + app/app/Services/Sms/SmsSendResult.php | 18 + app/config/services.php | 15 + ...6_07_23_100000_create_sales_sms_tables.php | 149 + ...tend_balance_transactions_type_for_sms.php | 36 + ..._related_index_to_balance_transactions.php | 40 + app/phpstan-baseline.neon | 8 +- app/resources/js/api/sales.ts | 145 + app/resources/js/router/index.ts | 6 + app/resources/js/views/sales/SalesSmsView.vue | 776 +++++ app/routes/web.php | 8 + .../Sales/AdAudienceIntakeOperatorTest.php | 54 + .../Feature/Sales/AdAudienceIntakeTest.php | 84 + .../Feature/Sales/SalesSmsScreenTest.php | 127 + .../Feature/Sales/SendSmsCampaignJobTest.php | 118 + .../Feature/Sales/SmsChargeServiceTest.php | 82 + app/tests/Feature/Sales/SmsModelsTest.php | 48 + .../Sales/SmsRecipientSelectorTest.php | 93 + app/tests/Feature/Sales/SmsSchemaTest.php | 60 + .../Services/SnapshotHelperTimeOfDayTest.php | 54 + app/tests/Frontend/SalesSmsView.spec.ts | 42 + app/tests/Pest.php | 27 +- app/tests/Unit/Sms/FakeSmsProviderTest.php | 27 + app/tests/Unit/Sms/SmsRouterTest.php | 81 + .../Unit/Sms/SmsSegmentCalculatorTest.php | 29 + cspell-words.txt | 16 + db/CHANGELOG_schema.md | 64 +- .../plans/2026-07-20-sms-progrev-modul.md | 2709 +++++++++++++++++ .../2026-07-20-sms-progrev-modul-design.md | 352 +++ моя/sales-finder/salesfinder/adaudience.py | 21 +- .../tests/test_ad_payload_contract.py | 101 + моя/sales-finder/tests/test_adaudience.py | 34 +- моя/sales-finder/web/app.py | 9 +- моя/sales-finder/web/bulk.py | 11 +- 53 files changed, 6507 insertions(+), 18 deletions(-) create mode 100644 app/app/Http/Controllers/Api/Sales/SalesSmsController.php create mode 100644 app/app/Jobs/SendSmsCampaignJob.php create mode 100644 app/app/Models/SalesSmsCampaign.php create mode 100644 app/app/Models/SalesSmsMessage.php create mode 100644 app/app/Models/SalesSmsOptout.php create mode 100644 app/app/Models/SalesSmsSender.php create mode 100644 app/app/Services/Sms/Providers/FakeSmsProvider.php create mode 100644 app/app/Services/Sms/SmsChargeService.php create mode 100644 app/app/Services/Sms/SmsOutgoing.php create mode 100644 app/app/Services/Sms/SmsPlan.php create mode 100644 app/app/Services/Sms/SmsProvider.php create mode 100644 app/app/Services/Sms/SmsRecipientSelector.php create mode 100644 app/app/Services/Sms/SmsRouter.php create mode 100644 app/app/Services/Sms/SmsSegmentCalculator.php create mode 100644 app/app/Services/Sms/SmsSendException.php create mode 100644 app/app/Services/Sms/SmsSendResult.php create mode 100644 app/database/migrations/2026_07_23_100000_create_sales_sms_tables.php create mode 100644 app/database/migrations/2026_07_23_100100_extend_balance_transactions_type_for_sms.php create mode 100644 app/database/migrations/2026_07_23_100200_add_related_index_to_balance_transactions.php create mode 100644 app/resources/js/views/sales/SalesSmsView.vue create mode 100644 app/tests/Feature/Sales/AdAudienceIntakeOperatorTest.php create mode 100644 app/tests/Feature/Sales/SalesSmsScreenTest.php create mode 100644 app/tests/Feature/Sales/SendSmsCampaignJobTest.php create mode 100644 app/tests/Feature/Sales/SmsChargeServiceTest.php create mode 100644 app/tests/Feature/Sales/SmsModelsTest.php create mode 100644 app/tests/Feature/Sales/SmsRecipientSelectorTest.php create mode 100644 app/tests/Feature/Sales/SmsSchemaTest.php create mode 100644 app/tests/Feature/Services/SnapshotHelperTimeOfDayTest.php create mode 100644 app/tests/Frontend/SalesSmsView.spec.ts create mode 100644 app/tests/Unit/Sms/FakeSmsProviderTest.php create mode 100644 app/tests/Unit/Sms/SmsRouterTest.php create mode 100644 app/tests/Unit/Sms/SmsSegmentCalculatorTest.php create mode 100644 docs/superpowers/plans/2026-07-20-sms-progrev-modul.md create mode 100644 docs/superpowers/specs/2026-07-20-sms-progrev-modul-design.md create mode 100644 моя/sales-finder/tests/test_ad_payload_contract.py diff --git a/app/app/Http/Controllers/Api/Sales/SalesIntegrationController.php b/app/app/Http/Controllers/Api/Sales/SalesIntegrationController.php index ea0d16f2..68069455 100644 --- a/app/app/Http/Controllers/Api/Sales/SalesIntegrationController.php +++ b/app/app/Http/Controllers/Api/Sales/SalesIntegrationController.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Controller; use App\Models\SalesProspect; use App\Models\SalesUser; use App\Services\Sales\AdAudienceIntake; +use Closure; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -114,7 +115,23 @@ class SalesIntegrationController extends Controller 'firms.*.payload' => ['nullable', 'array'], 'firms.*.contacts' => ['nullable', 'array'], 'firms.*.phones' => ['required', 'array', 'min:1'], - 'firms.*.phones.*' => ['required', 'string', 'regex:/^7\d{10}$/'], + 'firms.*.phones.*' => [ + 'required', + function (string $attribute, mixed $value, Closure $fail): void { + // Два формата разом: новый — объект с оператором, старый — голая + // строка. Python-служба переезжает не мгновенно, ломать приём нельзя. + // Строгость к САМОМУ номеру не теряется ни в одном из них. + $phone = is_array($value) ? ($value['phone'] ?? null) : $value; + + if (! is_string($phone) || preg_match('/^7\d{10}$/', $phone) !== 1) { + $fail('Телефон должен быть в формате 7XXXXXXXXXX.'); + } + }, + ], + // Длины — ровно как колонки sales_ad_audience_phones (VARCHAR(30)/VARCHAR(12)), + // иначе длинное значение упало бы не на входе, а уже на записи в БД. + 'firms.*.phones.*.operator' => ['nullable', 'string', 'max:30'], + 'firms.*.phones.*.phone_type' => ['nullable', 'string', 'max:12'], 'firms.*.channels' => ['nullable', 'string', 'in:yandex,vk,both'], 'firms.*.ch_yandex' => ['nullable', 'boolean'], 'firms.*.ch_vk' => ['nullable', 'boolean'], diff --git a/app/app/Http/Controllers/Api/Sales/SalesSmsController.php b/app/app/Http/Controllers/Api/Sales/SalesSmsController.php new file mode 100644 index 00000000..fe4ba9e6 --- /dev/null +++ b/app/app/Http/Controllers/Api/Sales/SalesSmsController.php @@ -0,0 +1,158 @@ +denyIfNotHead($request)) !== null) { + return $resp; + } + + return response()->json([ + 'campaigns' => SalesSmsCampaign::orderByDesc('id')->limit(50)->get(), + 'sandbox' => (bool) config('services.sms.sandbox'), + 'senders' => SalesSmsSender::orderBy('name')->get(), + ]); + } + + /** Что уйдёт и почём — ДО отправки. */ + public function preview(Request $request): JsonResponse + { + if (($resp = $this->denyIfNotHead($request)) !== null) { + return $resp; + } + + $data = $request->validate([ + 'body' => 'required|string|max:1000', + 'phones' => 'required|array|min:1', + 'phones.*' => 'string|size:11', + ]); + + $plan = (new SmsRecipientSelector($this->router))->build($data['phones']); + $segments = $this->calculator->segments($data['body']); + + $cost = 0; + + foreach ($plan->sendable as $row) { + $provider = $this->router->pick((string) $row->operator); + $cost += $segments * ($provider?->priceKopecks((string) $row->operator) ?? 0); + } + + return response()->json([ + 'segments' => $segments, + 'sendable_count' => count($plan->sendable), + 'skipped' => $plan->skipped, + 'estimated_cost_kopecks' => $cost, + ]); + } + + /** Номера отмеченных фирм — чтобы начальник отмечал галочками, а не вбивал руками. */ + public function recipients(Request $request): JsonResponse + { + if (($resp = $this->denyIfNotHead($request)) !== null) { + return $resp; + } + + $data = $request->validate([ + 'firm_ids' => 'required|array|min:1', + 'firm_ids.*' => 'integer', + ]); + + $phones = SalesAdAudiencePhone::query() + ->whereIn('firm_id', $data['firm_ids']) + ->whereNull('removed_at') + ->where('state', 'active') + ->orderBy('firm_name') + ->get(['phone', 'firm_name', 'city', 'operator', 'phone_type']); + + return response()->json(['phones' => $phones]); + } + + public function store(Request $request): JsonResponse + { + if (($resp = $this->denyIfNotHead($request)) !== null) { + return $resp; + } + + $data = $request->validate([ + 'title' => 'required|string|max:200', + 'body' => 'required|string|min:1|max:1000', + 'phones' => 'required|array|min:1', + 'phones.*' => 'string|size:11', + 'sender_id' => 'nullable|integer', + ]); + + /** @var SalesUser $user */ + $user = $request->user('sales'); + + // Расчётная цена считается здесь и хранится на кампании: именно её + // спишет джоб. Без неё списание получило бы ноль. + $plan = (new SmsRecipientSelector($this->router))->build($data['phones']); + $segments = $this->calculator->segments($data['body']); + $estimated = 0; + + foreach ($plan->sendable as $row) { + $provider = $this->router->pick((string) $row->operator); + $estimated += $segments * ($provider?->priceKopecks((string) $row->operator) ?? 0); + } + + $campaign = SalesSmsCampaign::create([ + 'title' => $data['title'], + 'body' => $data['body'], + 'sender_id' => $data['sender_id'] ?? null, + 'created_by' => $user->id, + 'status' => SalesSmsCampaign::STATUS_QUEUED, + 'planned_count' => count($data['phones']), + 'estimated_cost_kopecks' => $estimated, + ]); + + SendSmsCampaignJob::dispatch($campaign->id, $data['phones']); + + return response()->json(['campaign' => $campaign], 201); + } + + public function show(Request $request, int $id): JsonResponse + { + if (($resp = $this->denyIfNotHead($request)) !== null) { + return $resp; + } + + $campaign = SalesSmsCampaign::with('messages')->findOrFail($id); + + return response()->json(['campaign' => $campaign]); + } + + private function denyIfNotHead(Request $request): ?JsonResponse + { + /** @var SalesUser $user */ + $user = $request->user('sales'); + + if (! $user->isHead()) { + return response()->json(['message' => 'Доступно только начальнику отдела.'], 403); + } + + return null; + } +} diff --git a/app/app/Jobs/SendSmsCampaignJob.php b/app/app/Jobs/SendSmsCampaignJob.php new file mode 100644 index 00000000..68e73e51 --- /dev/null +++ b/app/app/Jobs/SendSmsCampaignJob.php @@ -0,0 +1,237 @@ + $phones */ + public function __construct( + private readonly int $campaignId, + private readonly array $phones, + ) {} + + public function handle( + ?SmsRecipientSelector $selector = null, + ?SmsRouter $router = null, + ?SmsSegmentCalculator $calculator = null, + ?SmsChargeService $charge = null, + ): void { + $router ??= app(SmsRouter::class); + $selector ??= new SmsRecipientSelector($router); + $calculator ??= new SmsSegmentCalculator; + + $campaign = SalesSmsCampaign::find($this->campaignId); + + if ($campaign === null) { + Log::warning('sms.campaign_not_found', ['campaign_id' => $this->campaignId]); + + return; + } + + $sender = $campaign->sender; + + if ($sender === null || ! $sender->isActive()) { + $campaign->update([ + 'status' => SalesSmsCampaign::STATUS_FAILED, + 'last_error' => 'Не согласовано имя отправителя — рассылка не запускалась.', + 'finished_at' => now(), + ]); + + Log::warning('sms.sender_not_active', ['campaign_id' => $campaign->id]); + + return; + } + + $campaign->update(['status' => SalesSmsCampaign::STATUS_SENDING, 'started_at' => now()]); + + $already = SalesSmsMessage::where('campaign_id', $campaign->id)->pluck('phone')->all(); + $plan = $selector->build($this->phones, $campaign->tenant_id); + + // Списываем ДО отправки, по расчётной цене. Не хватило — не уходит ничего. + $charge ??= new SmsChargeService; + $estimatedRub = bcdiv((string) $campaign->estimated_cost_kopecks, '100', 2); + + try { + $charge->charge($campaign, $estimatedRub); + } catch (InsufficientBalanceException $e) { + foreach ($plan->sendable as $row) { + if (in_array($row->phone, $already, true)) { + continue; + } + + SalesSmsMessage::create([ + 'campaign_id' => $campaign->id, + 'phone' => $row->phone, + 'status' => SalesSmsMessage::SKIP_NO_FUNDS, + 'segments' => 0, + 'cost_kopecks' => 0, + ]); + } + + $campaign->update([ + 'status' => SalesSmsCampaign::STATUS_FAILED, + 'last_error' => 'Не хватило денег на балансе — не отправлено ни одной СМС.', + 'skipped_count' => count($plan->sendable), + 'finished_at' => now(), + ]); + + Log::warning('sms.insufficient_balance', ['campaign_id' => $campaign->id]); + + return; + } + + $sent = 0; + $failed = 0; + $skippedCount = 0; + $cost = 0; + + foreach ($plan->skipped as $phone => $reason) { + if (in_array($phone, $already, true)) { + continue; + } + + SalesSmsMessage::create([ + 'campaign_id' => $campaign->id, + 'phone' => $phone, + 'status' => $reason, + 'segments' => 0, + 'cost_kopecks' => 0, + ]); + + $skippedCount++; + } + + foreach ($plan->sendable as $row) { + if (in_array($row->phone, $already, true)) { + continue; + } + + $operator = (string) $row->operator; + $provider = $router->pick($operator); + + if ($provider === null) { + SalesSmsMessage::create([ + 'campaign_id' => $campaign->id, + 'phone' => $row->phone, + 'status' => SalesSmsMessage::SKIP_NO_ROUTE, + 'segments' => 0, + 'cost_kopecks' => 0, + ]); + $skippedCount++; + + continue; + } + + $segments = $calculator->segments($campaign->body); + + try { + $result = $provider->send(new SmsOutgoing( + phone: $row->phone, + body: $campaign->body, + senderName: $sender->name, + operator: $operator, + segments: $segments, + )); + + SalesSmsMessage::create([ + 'campaign_id' => $campaign->id, + 'phone' => $row->phone, + 'phone_id' => $row->id, + 'operator' => $operator, + 'phone_type' => $row->phone_type, + 'provider_key' => $provider->key(), + 'status' => $provider->key() === 'fake' + ? SalesSmsMessage::STATUS_FAKE_SENT + : SalesSmsMessage::STATUS_SENT, + 'segments' => $result->segments, + 'cost_kopecks' => $result->costKopecks, + 'provider_message_id' => $result->providerMessageId, + 'sent_at' => $result->acceptedAt, + ]); + + $sent++; + $cost += $result->costKopecks; + } catch (SmsSendException $e) { + SalesSmsMessage::create([ + 'campaign_id' => $campaign->id, + 'phone' => $row->phone, + 'phone_id' => $row->id, + 'operator' => $operator, + 'provider_key' => $provider->key(), + 'status' => SalesSmsMessage::STATUS_FAILED, + 'segments' => 0, + 'cost_kopecks' => 0, + 'error' => $e->getMessage(), + ]); + + $failed++; + + Log::warning('sms.send_failed', [ + 'campaign_id' => $campaign->id, + 'provider' => $provider->key(), + 'terminal' => $e->terminal, + ]); + } + } + + $campaign->update([ + 'status' => SalesSmsCampaign::STATUS_DONE, + 'sent_count' => $campaign->sent_count + $sent, + 'failed_count' => $campaign->failed_count + $failed, + 'skipped_count' => $campaign->skipped_count + $skippedCount, + 'actual_cost_kopecks' => $campaign->actual_cost_kopecks + $cost, + 'finished_at' => now(), + ]); + + Log::info('sms.campaign_done', [ + 'campaign_id' => $campaign->id, + 'sent' => $sent, 'failed' => $failed, 'skipped' => $skippedCount, + ]); + } + + public function failed(Throwable $e): void + { + Log::error('sms.campaign_failed_permanently', [ + 'campaign_id' => $this->campaignId, + 'error' => $e->getMessage(), + ]); + + SalesSmsCampaign::whereKey($this->campaignId)->update([ + 'status' => SalesSmsCampaign::STATUS_FAILED, + 'last_error' => $e->getMessage(), + 'finished_at' => now(), + ]); + } +} diff --git a/app/app/Models/BalanceTransaction.php b/app/app/Models/BalanceTransaction.php index f949da2f..fb3f23d7 100644 --- a/app/app/Models/BalanceTransaction.php +++ b/app/app/Models/BalanceTransaction.php @@ -44,6 +44,8 @@ class BalanceTransaction extends Model public const TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge'; + public const TYPE_SMS_CHARGE = 'sms_charge'; + public $timestamps = false; protected $fillable = [ diff --git a/app/app/Models/SalesAdAudiencePhone.php b/app/app/Models/SalesAdAudiencePhone.php index afff8ca5..b1bce727 100644 --- a/app/app/Models/SalesAdAudiencePhone.php +++ b/app/app/Models/SalesAdAudiencePhone.php @@ -25,6 +25,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property Carbon|null $synced_at * @property Carbon|null $removed_at * @property Carbon|null $resume_at + * @property string|null $operator + * @property string|null $phone_type */ class SalesAdAudiencePhone extends Model { @@ -38,6 +40,7 @@ class SalesAdAudiencePhone extends Model 'phone', 'firm_inn', 'firm_name', 'city', 'rubric', 'added_at', 'expires_at', 'synced_at', 'removed_at', 'firm_id', 'state', 'resume_at', + 'operator', 'phone_type', ]; protected $casts = [ diff --git a/app/app/Models/SalesSmsCampaign.php b/app/app/Models/SalesSmsCampaign.php new file mode 100644 index 00000000..4a335108 --- /dev/null +++ b/app/app/Models/SalesSmsCampaign.php @@ -0,0 +1,73 @@ + 'datetime', + 'finished_at' => 'datetime', + ]; + + /** @return HasMany */ + public function messages(): HasMany + { + return $this->hasMany(SalesSmsMessage::class, 'campaign_id'); + } + + /** @return BelongsTo */ + public function sender(): BelongsTo + { + return $this->belongsTo(SalesSmsSender::class, 'sender_id'); + } +} diff --git a/app/app/Models/SalesSmsMessage.php b/app/app/Models/SalesSmsMessage.php new file mode 100644 index 00000000..7996c6e2 --- /dev/null +++ b/app/app/Models/SalesSmsMessage.php @@ -0,0 +1,74 @@ + 'datetime', + 'delivered_at' => 'datetime', + ]; + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(SalesSmsCampaign::class, 'campaign_id'); + } +} diff --git a/app/app/Models/SalesSmsOptout.php b/app/app/Models/SalesSmsOptout.php new file mode 100644 index 00000000..5c6a8f82 --- /dev/null +++ b/app/app/Models/SalesSmsOptout.php @@ -0,0 +1,31 @@ + 'datetime']; +} diff --git a/app/app/Models/SalesSmsSender.php b/app/app/Models/SalesSmsSender.php new file mode 100644 index 00000000..d43c1140 --- /dev/null +++ b/app/app/Models/SalesSmsSender.php @@ -0,0 +1,48 @@ + 'datetime', + 'approved_at' => 'datetime', + ]; + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE; + } +} diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php index 5f79fac4..9873b619 100644 --- a/app/app/Providers/AppServiceProvider.php +++ b/app/app/Providers/AppServiceProvider.php @@ -16,6 +16,8 @@ use App\Services\Captcha\YandexSmartCaptchaVerifier; use App\Services\DaData\DaDataPartyClient; use App\Services\DaData\NullPartyLookup; use App\Services\DaData\PartyLookup; +use App\Services\Sms\Providers\FakeSmsProvider; +use App\Services\Sms\SmsRouter; use App\Services\Supplier\Channel\AjaxProjectChannel; use App\Services\Supplier\Channel\FailoverProjectChannel; use App\Services\Supplier\Channel\FormProjectChannel; @@ -94,6 +96,19 @@ class AppServiceProvider extends ServiceProvider return $manager->driverFor($gateway); }); + + // Маршрутизатор СМС (Task 8). Состав провайдеров зависит от режима: + // песочница включена — работает только заглушка, живые провайдеры не + // вызываются, деньги оператору не уходят. + $this->app->singleton(SmsRouter::class, function () { + $providers = []; + + if (config('services.sms.sandbox')) { + $providers[] = new FakeSmsProvider; + } + + return new SmsRouter($providers); + }); } /** diff --git a/app/app/Services/Sales/AdAudienceIntake.php b/app/app/Services/Sales/AdAudienceIntake.php index 0c8a5871..a52b2f78 100644 --- a/app/app/Services/Sales/AdAudienceIntake.php +++ b/app/app/Services/Sales/AdAudienceIntake.php @@ -66,10 +66,18 @@ class AdAudienceIntake $added++; } - /** @var array $rawPhones */ + /** @var array $rawPhones */ $rawPhones = is_array($raw['phones'] ?? null) ? $raw['phones'] : []; - foreach ($rawPhones as $phone) { + foreach ($rawPhones as $rawPhone) { + // Два формата разом: новый — словарь с оператором, старый — голая + // строка. Python-служба переезжает не мгновенно, ломать приём нельзя. + $phone = is_array($rawPhone) ? (string) ($rawPhone['phone'] ?? '') : (string) $rawPhone; + + if ($phone === '') { + continue; + } + $row = SalesAdAudiencePhone::firstOrNew(['phone' => $phone]); $row->fill([ 'firm_id' => $firm->id, @@ -82,6 +90,11 @@ class AdAudienceIntake 'removed_at' => null, 'synced_at' => null, ]); + if (is_array($rawPhone)) { + $row->operator = $rawPhone['operator'] ?? null; + $row->phone_type = $rawPhone['phone_type'] ?? null; + } + $row->added_at ??= now(); $row->save(); $phones++; diff --git a/app/app/Services/Sms/Providers/FakeSmsProvider.php b/app/app/Services/Sms/Providers/FakeSmsProvider.php new file mode 100644 index 00000000..697c1c13 --- /dev/null +++ b/app/app/Services/Sms/Providers/FakeSmsProvider.php @@ -0,0 +1,54 @@ + $message->phone, + 'operator' => $message->operator, + 'segments' => $message->segments, + ]); + + return new SmsSendResult( + providerMessageId: 'fake-'.Str::uuid()->toString(), + segments: $message->segments, + costKopecks: 0, + acceptedAt: CarbonImmutable::now(), + ); + } +} diff --git a/app/app/Services/Sms/SmsChargeService.php b/app/app/Services/Sms/SmsChargeService.php new file mode 100644 index 00000000..2dfee0de --- /dev/null +++ b/app/app/Services/Sms/SmsChargeService.php @@ -0,0 +1,108 @@ +tenant_id === null) { + return; + } + + DB::transaction(function () use ($campaign, $priceRub): void { + // Настоящий замок: таблица на pgsql, транзакция на pgsql. + /** @var Tenant $tenant */ + $tenant = Tenant::whereKey($campaign->tenant_id)->lockForUpdate()->firstOrFail(); + + // Проверка идемпотентности — строго ПОСЛЕ взятия замка, иначе два + // работника успели бы проверить оба до того, как первый записал. + $alreadyCharged = BalanceTransaction::query() + ->where('related_type', SalesSmsCampaign::class) + ->where('related_id', $campaign->id) + ->exists(); + + if ($alreadyCharged) { + return; + } + + if (bccomp($priceRub, '0', 2) === 0) { + return; + } + + if (bccomp((string) $tenant->balance_rub, $priceRub, 2) < 0) { + throw new InsufficientBalanceException( + priceKopecks: (int) bcmul($priceRub, '100', 0), + balanceRub: (string) $tenant->balance_rub, + ); + } + + $newBalance = bcsub((string) $tenant->balance_rub, $priceRub, 2); + + DB::table('tenants')->where('id', $tenant->id)->update(['balance_rub' => $newBalance]); + + $tx = BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_SMS_CHARGE, + 'amount_rub' => '-'.$priceRub, + 'amount_leads' => null, + 'balance_rub_after' => $newBalance, + 'balance_leads_after' => null, + 'related_type' => SalesSmsCampaign::class, + 'related_id' => $campaign->id, + 'created_at' => now(), + ]); + + // Справочная пометка для экрана — на другом соединении. Деньги уже + // списаны корректно, и ронять всё из-за неудачной пометки нельзя: + // защита от повторного списания держится не на ней. + try { + SalesSmsCampaign::whereKey($campaign->id)->update(['balance_transaction_id' => $tx->id]); + } catch (Throwable $e) { + Log::warning('sms.charge_marker_write_failed', [ + 'campaign_id' => $campaign->id, + 'balance_transaction_id' => $tx->id, + 'error' => $e->getMessage(), + ]); + } + }); + } +} diff --git a/app/app/Services/Sms/SmsOutgoing.php b/app/app/Services/Sms/SmsOutgoing.php new file mode 100644 index 00000000..89396357 --- /dev/null +++ b/app/app/Services/Sms/SmsOutgoing.php @@ -0,0 +1,17 @@ + $sendable + * @param array $skipped номер => причина + */ + public function __construct( + public array $sendable, + public array $skipped, + ) {} +} diff --git a/app/app/Services/Sms/SmsProvider.php b/app/app/Services/Sms/SmsProvider.php new file mode 100644 index 00000000..0f8fc8c0 --- /dev/null +++ b/app/app/Services/Sms/SmsProvider.php @@ -0,0 +1,28 @@ + + */ + public function servesOperators(): array; + + /** Цена одного сегмента в копейках для данного оператора. */ + public function priceKopecks(string $operator): int; + + /** @throws SmsSendException при отказе провайдера. */ + public function send(SmsOutgoing $message): SmsSendResult; +} diff --git a/app/app/Services/Sms/SmsRecipientSelector.php b/app/app/Services/Sms/SmsRecipientSelector.php new file mode 100644 index 00000000..8630bf6a --- /dev/null +++ b/app/app/Services/Sms/SmsRecipientSelector.php @@ -0,0 +1,74 @@ + $phones */ + public function build(array $phones, ?int $tenantId = null): SmsPlan + { + $unique = array_values(array_unique($phones)); + + $optouts = SalesSmsOptout::query() + ->when($tenantId === null, fn ($q) => $q->whereNull('tenant_id')) + ->when($tenantId !== null, fn ($q) => $q->where('tenant_id', $tenantId)) + ->whereIn('phone', $unique) + ->pluck('phone') + ->all(); + + /** @var array $rows */ + $rows = SalesAdAudiencePhone::whereIn('phone', $unique)->get()->keyBy('phone')->all(); + + $sendable = []; + $skipped = []; + + foreach ($unique as $phone) { + if (in_array($phone, $optouts, true)) { + $skipped[$phone] = SalesSmsMessage::SKIP_OPTOUT; + + continue; + } + + $row = $rows[$phone] ?? null; + + if ($row === null || $row->removed_at !== null || $row->state !== 'active') { + $skipped[$phone] = SalesSmsMessage::SKIP_INACTIVE; + + continue; + } + + // Страховка: по построению в прогрев попадают только мобильные, + // но номер мог прийти мимо обычного пути. + if ($row->phone_type !== null && $row->phone_type !== 'mobile') { + $skipped[$phone] = SalesSmsMessage::SKIP_NOT_MOBILE; + + continue; + } + + if ($this->router->pick((string) $row->operator) === null) { + $skipped[$phone] = SalesSmsMessage::SKIP_NO_ROUTE; + + continue; + } + + $sendable[] = $row; + } + + return new SmsPlan($sendable, $skipped); + } +} diff --git a/app/app/Services/Sms/SmsRouter.php b/app/app/Services/Sms/SmsRouter.php new file mode 100644 index 00000000..b8f6255f --- /dev/null +++ b/app/app/Services/Sms/SmsRouter.php @@ -0,0 +1,45 @@ + $providers */ + public function __construct(private readonly array $providers) {} + + public function pick(string $operator): ?SmsProvider + { + $exact = []; + $universal = []; + + foreach ($this->providers as $provider) { + $serves = $provider->servesOperators(); + + if ($operator !== '' && in_array($operator, $serves, true)) { + $exact[] = $provider; + } elseif (in_array('*', $serves, true)) { + $universal[] = $provider; + } + } + + $candidates = $exact !== [] ? $exact : $universal; + + if ($candidates === []) { + return null; + } + + usort( + $candidates, + fn (SmsProvider $a, SmsProvider $b) => $a->priceKopecks($operator) <=> $b->priceKopecks($operator), + ); + + return $candidates[0]; + } +} diff --git a/app/app/Services/Sms/SmsSegmentCalculator.php b/app/app/Services/Sms/SmsSegmentCalculator.php new file mode 100644 index 00000000..0e2d6713 --- /dev/null +++ b/app/app/Services/Sms/SmsSegmentCalculator.php @@ -0,0 +1,36 @@ +segments($body) * $segmentPriceKopecks; + } +} diff --git a/app/app/Services/Sms/SmsSendException.php b/app/app/Services/Sms/SmsSendException.php new file mode 100644 index 00000000..ac9c22db --- /dev/null +++ b/app/app/Services/Sms/SmsSendException.php @@ -0,0 +1,19 @@ + env('KEYSO_TOPUP_URL', 'https://www.keys.so/ru/tarif'), ], + 'sms' => [ + // Песочница: провайдеры не вызываются, отправка только помечается в журнале. + // Пока имя отправителя не согласовано операторами — единственный рабочий режим. + 'sandbox' => (bool) env('SMS_SANDBOX', true), + + '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), + ], + ], + ]; diff --git a/app/database/migrations/2026_07_23_100000_create_sales_sms_tables.php b/app/database/migrations/2026_07_23_100000_create_sales_sms_tables.php new file mode 100644 index 00000000..60a8546b --- /dev/null +++ b/app/database/migrations/2026_07_23_100000_create_sales_sms_tables.php @@ -0,0 +1,149 @@ +statement(<<<'SQL' + CREATE TABLE IF NOT EXISTS sales_sms_senders ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT, + name VARCHAR(11) NOT NULL, + provider_key VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft','pending','active','rejected')), + monthly_fee_kopecks INTEGER, + submitted_at TIMESTAMPTZ, + approved_at TIMESTAMPTZ, + rejected_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $db->statement(<<<'SQL' + CREATE UNIQUE INDEX IF NOT EXISTS uniq_sms_sender + ON sales_sms_senders (COALESCE(tenant_id, 0), name, provider_key) + SQL); + + $db->statement(<<<'SQL' + CREATE TABLE IF NOT EXISTS sales_sms_campaigns ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT, + sender_id BIGINT REFERENCES sales_sms_senders(id), + title VARCHAR(200) NOT NULL, + body TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft','queued','sending','done','failed','canceled')), + created_by BIGINT NOT NULL, + planned_count INTEGER NOT NULL DEFAULT 0, + sent_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + skipped_count INTEGER NOT NULL DEFAULT 0, + estimated_cost_kopecks INTEGER NOT NULL DEFAULT 0, + actual_cost_kopecks INTEGER NOT NULL DEFAULT 0, + balance_transaction_id BIGINT, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $db->statement(<<<'SQL' + CREATE TABLE IF NOT EXISTS sales_sms_messages ( + id BIGSERIAL PRIMARY KEY, + campaign_id BIGINT NOT NULL REFERENCES sales_sms_campaigns(id) ON DELETE CASCADE, + phone_id BIGINT, + phone VARCHAR(11) NOT NULL, + operator VARCHAR(30), + phone_type VARCHAR(12), + provider_key VARCHAR(20), + status VARCHAR(24) NOT NULL DEFAULT 'pending', + segments SMALLINT NOT NULL DEFAULT 1, + cost_kopecks INTEGER NOT NULL DEFAULT 0, + provider_message_id VARCHAR(64), + sent_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ, + error TEXT + ) + SQL); + + // Дубль внутри рассылки невозможен на уровне БД, а не только в коде. + $db->statement(<<<'SQL' + CREATE UNIQUE INDEX IF NOT EXISTS uniq_sms_message_per_campaign + ON sales_sms_messages (campaign_id, phone) + SQL); + + $db->statement(<<<'SQL' + CREATE INDEX IF NOT EXISTS idx_sms_messages_campaign + ON sales_sms_messages (campaign_id, status) + SQL); + + $db->statement(<<<'SQL' + CREATE TABLE IF NOT EXISTS sales_sms_optouts ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT, + phone VARCHAR(11) NOT NULL, + reason VARCHAR(30) NOT NULL DEFAULT 'manual' + CHECK (reason IN ('manual','reply_stop','complaint','operator')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $db->statement(<<<'SQL' + CREATE UNIQUE INDEX IF NOT EXISTS uniq_sms_optout + ON sales_sms_optouts (COALESCE(tenant_id, 0), phone) + SQL); + + // Оператор и тип номера приходят из Поиска клиентов (уже оплачены ДаДате). + $db->statement('ALTER TABLE sales_ad_audience_phones ADD COLUMN IF NOT EXISTS operator VARCHAR(30)'); + $db->statement('ALTER TABLE sales_ad_audience_phones ADD COLUMN IF NOT EXISTS phone_type VARCHAR(12)'); + + $db->statement(<<<'SQL' + DO $$ + DECLARE target TEXT; + BEGIN + FOREACH target IN ARRAY ARRAY['crm_admin_user','crm_supplier_worker'] LOOP + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = target) THEN + EXECUTE format('GRANT SELECT, INSERT, UPDATE ON sales_sms_senders TO %I', target); + EXECUTE format('GRANT SELECT, INSERT, UPDATE ON sales_sms_campaigns TO %I', target); + EXECUTE format('GRANT SELECT, INSERT, UPDATE ON sales_sms_messages TO %I', target); + EXECUTE format('GRANT SELECT, INSERT, DELETE ON sales_sms_optouts TO %I', target); + EXECUTE format('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO %I', target); + END IF; + END LOOP; + END $$ + SQL); + } + + public function down(): void + { + $db = DB::connection('pgsql_supplier'); + $db->statement('DROP TABLE IF EXISTS sales_sms_messages CASCADE'); + $db->statement('DROP TABLE IF EXISTS sales_sms_campaigns CASCADE'); + $db->statement('DROP TABLE IF EXISTS sales_sms_senders CASCADE'); + $db->statement('DROP TABLE IF EXISTS sales_sms_optouts CASCADE'); + $db->statement('ALTER TABLE sales_ad_audience_phones DROP COLUMN IF EXISTS operator'); + $db->statement('ALTER TABLE sales_ad_audience_phones DROP COLUMN IF EXISTS phone_type'); + } +}; diff --git a/app/database/migrations/2026_07_23_100100_extend_balance_transactions_type_for_sms.php b/app/database/migrations/2026_07_23_100100_extend_balance_transactions_type_for_sms.php new file mode 100644 index 00000000..4def4e97 --- /dev/null +++ b/app/database/migrations/2026_07_23_100100_extend_balance_transactions_type_for_sms.php @@ -0,0 +1,36 @@ +; + estimated_cost_kopecks: number; +} + +/** Номер из прогрева, привязанный к отмеченной фирме. */ +export interface SalesSmsRecipientRow { + phone: string; + firm_name: string | null; + city: string | null; + operator: string | null; + phone_type: string | null; +} + +/** GET /api/sales/sms/campaigns (Bearer, начальник) — список рассылок, песочница, отправители. */ +export async function fetchSmsCampaigns(): Promise { + const { data } = await axios.get('/api/sales/sms/campaigns', { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + ...authHeaders(), + }, + }); + return data; +} + +/** GET /api/sales/sms/campaigns/{id} (Bearer, начальник) — карточка с журналом по номерам. */ +export async function fetchSmsCampaign( + id: number, +): Promise { + const { data } = await axios.get<{ + campaign: SalesSmsCampaignRow & { messages?: SalesSmsMessageRow[] }; + }>(`/api/sales/sms/campaigns/${id}`, { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + ...authHeaders(), + }, + }); + return data.campaign; +} + +/** POST /api/sales/sms/preview (Bearer, начальник) — цена и причины пропуска ДО отправки. */ +export async function previewSms(body: string, phones: string[]): Promise { + const { data } = await axios.post( + '/api/sales/sms/preview', + { body, phones }, + { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + ...authHeaders(), + }, + }, + ); + return data; +} + +/** POST /api/sales/sms/campaigns (Bearer, начальник) — запуск рассылки. */ +export async function createSmsCampaign(payload: { + title: string; + body: string; + phones: string[]; + sender_id: number | null; +}): Promise { + const { data } = await axios.post<{ campaign: SalesSmsCampaignRow }>( + '/api/sales/sms/campaigns', + payload, + { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + ...authHeaders(), + }, + }, + ); + return data.campaign; +} + +/** + * POST /api/sales/sms/recipients (Bearer, начальник) — номера отмеченных фирм. + * Начальник отмечает фирмы галочками, номера подтягиваются сами. + */ +export async function fetchSmsRecipients(firmIds: number[]): Promise { + const { data } = await axios.post<{ phones: SalesSmsRecipientRow[] }>( + '/api/sales/sms/recipients', + { firm_ids: firmIds }, + { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + ...authHeaders(), + }, + }, + ); + return data.phones; +} diff --git a/app/resources/js/router/index.ts b/app/resources/js/router/index.ts index a8c6c5d4..e65ce590 100644 --- a/app/resources/js/router/index.ts +++ b/app/resources/js/router/index.ts @@ -484,6 +484,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('../views/sales/SalesAdAudienceView.vue'), meta: { layout: 'sales', title: 'Реклама на кандидатов', salesAuth: true, salesBossOnly: true }, }, + { + path: '/sales/sms', + name: 'sales-sms', + component: () => import('../views/sales/SalesSmsView.vue'), + meta: { layout: 'sales', title: 'Прогрев СМС', salesAuth: true, salesBossOnly: true }, + }, // Error pages: 403/500 явные + catch-all 404 (всегда последний). { diff --git a/app/resources/js/views/sales/SalesSmsView.vue b/app/resources/js/views/sales/SalesSmsView.vue new file mode 100644 index 00000000..a24a1556 --- /dev/null +++ b/app/resources/js/views/sales/SalesSmsView.vue @@ -0,0 +1,776 @@ + + + + + diff --git a/app/routes/web.php b/app/routes/web.php index 0b1aecf1..179c9616 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Api\Sales\SalesInvoiceController; use App\Http\Controllers\Api\Sales\SalesManagersController; use App\Http\Controllers\Api\Sales\SalesPayoutController; use App\Http\Controllers\Api\Sales\SalesProspectController; +use App\Http\Controllers\Api\Sales\SalesSmsController; use App\Http\Controllers\Api\Sales\SalesTariffController; use Illuminate\Support\Facades\Route; @@ -312,6 +313,13 @@ Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('api/sales Route::post('/ad-audience/channels', [SalesAdAudienceController::class, 'channels']); // Task 3 (план трёх площадок): выгрузка списка номеров файлом для МТС (только head). Route::get('/ad-audience/mts-file', [SalesAdAudienceController::class, 'mtsFile']); + // Task 9 (план «Прогрев СМС»): список рассылок, предпросмотр цены, запуск (только head). + Route::get('/sms/campaigns', [SalesSmsController::class, 'index']); + Route::post('/sms/campaigns', [SalesSmsController::class, 'store']); + Route::get('/sms/campaigns/{id}', [SalesSmsController::class, 'show'])->whereNumber('id'); + Route::post('/sms/preview', [SalesSmsController::class, 'preview']); + // Task 10.5 (план «Прогрев СМС»): номера отмеченных галочками фирм (только head). + Route::post('/sms/recipients', [SalesSmsController::class, 'recipients']); // managers, dashboard }); diff --git a/app/tests/Feature/Sales/AdAudienceIntakeOperatorTest.php b/app/tests/Feature/Sales/AdAudienceIntakeOperatorTest.php new file mode 100644 index 00000000..73319221 --- /dev/null +++ b/app/tests/Feature/Sales/AdAudienceIntakeOperatorTest.php @@ -0,0 +1,54 @@ +ingest([[ + 'firm_name' => 'ООО Ромашка', + 'firm_inn' => '7700000001', + 'phones' => [ + ['phone' => '79990000001', 'operator' => 'ВымпелКом', 'phone_type' => 'mobile'], + ], + ]]); + + $row = SalesAdAudiencePhone::where('phone', '79990000001')->first(); + + expect($row)->not->toBeNull() + ->and($row->operator)->toBe('ВымпелКом') + ->and($row->phone_type)->toBe('mobile'); +}); + +it('принимает и старый формат — голый список номеров', function () { + (new AdAudienceIntake)->ingest([[ + 'firm_name' => 'ООО Старая', + 'firm_inn' => '7700000002', + 'phones' => ['79990000002'], + ]]); + + $row = SalesAdAudiencePhone::where('phone', '79990000002')->first(); + + expect($row)->not->toBeNull() + ->and($row->operator)->toBeNull(); +}); diff --git a/app/tests/Feature/Sales/AdAudienceIntakeTest.php b/app/tests/Feature/Sales/AdAudienceIntakeTest.php index 1eb9f8e2..1885fe22 100644 --- a/app/tests/Feature/Sales/AdAudienceIntakeTest.php +++ b/app/tests/Feature/Sales/AdAudienceIntakeTest.php @@ -157,3 +157,87 @@ it('отвергает выдуманную площадку', function () { ['X-Sales-Token' => 'test-token'] )->assertStatus(422); }); + +// ── Task 11: оператор номера доезжает из «Поиска клиентов» ─────────────────── +// +// 🔴 Эти тесты идут ЧЕРЕЗ HTTP намеренно. Проверка формата номера живёт в +// SalesIntegrationController, а не в AdAudienceIntake, поэтому тест, дёргающий +// сервис напрямую, дыру в валидации НЕ УВИДИТ: он будет зелёным, пока живой путь +// отдаёт 422 и не пишет ни строки. Ровно так эта дыра и пряталась. + +it('принимает номера со словарём через HTTP и сохраняет оператора', function () { + $this->postJson( + '/api/sales/integration/ad-audience', + warmupPayload(['phones' => [ + ['phone' => '79990000021', 'operator' => 'ВымпелКом', 'phone_type' => 'mobile'], + ]]), + ['X-Sales-Token' => 'test-token'] + )->assertOk(); + + $row = SalesAdAudiencePhone::where('phone', '79990000021')->first(); + + expect($row)->not->toBeNull() + ->and($row->operator)->toBe('ВымпелКом') + ->and($row->phone_type)->toBe('mobile'); +}); + +it('по-прежнему принимает старый формат — голые строки через HTTP', function () { + // Python-служба переезжает не мгновенно: пока часть посылок идёт старым + // форматом, приём обязан их брать, а не отбивать 422. + $this->postJson( + '/api/sales/integration/ad-audience', + warmupPayload(['phones' => ['79990000022']]), + ['X-Sales-Token' => 'test-token'] + )->assertOk(); + + $row = SalesAdAudiencePhone::where('phone', '79990000022')->first(); + + expect($row)->not->toBeNull() + ->and($row->operator)->toBeNull(); +}); + +it('отвергает мусор вместо номера в обоих форматах', function () { + $headers = ['X-Sales-Token' => 'test-token']; + + // старый формат — голая строка не по формату Яндекса + $this->postJson( + '/api/sales/integration/ad-audience', + warmupPayload(['phones' => ['не-телефон']]), + $headers + )->assertStatus(422); + + // новый формат — строгость к САМОМУ номеру теряться не должна оттого, + // что он приехал завёрнутым в словарь + $this->postJson( + '/api/sales/integration/ad-audience', + warmupPayload(['phones' => [['phone' => 'не-телефон', 'operator' => 'ВымпелКом']]]), + $headers + )->assertStatus(422); + + // словарь вообще без номера + $this->postJson( + '/api/sales/integration/ad-audience', + warmupPayload(['phones' => [['operator' => 'ВымпелКом']]]), + $headers + )->assertStatus(422); +}); + +it('отвергает оператора длиннее колонки в базе', function () { + // operator VARCHAR(30) / phone_type VARCHAR(12): без ограничения в проверке + // длинное значение упало бы уже на записи в БД, а не на входе. + $this->postJson( + '/api/sales/integration/ad-audience', + warmupPayload(['phones' => [ + ['phone' => '79990000023', 'operator' => str_repeat('я', 31)], + ]]), + ['X-Sales-Token' => 'test-token'] + )->assertStatus(422); + + $this->postJson( + '/api/sales/integration/ad-audience', + warmupPayload(['phones' => [ + ['phone' => '79990000023', 'phone_type' => str_repeat('m', 13)], + ]]), + ['X-Sales-Token' => 'test-token'] + )->assertStatus(422); +}); diff --git a/app/tests/Feature/Sales/SalesSmsScreenTest.php b/app/tests/Feature/Sales/SalesSmsScreenTest.php new file mode 100644 index 00000000..e0fdcade --- /dev/null +++ b/app/tests/Feature/Sales/SalesSmsScreenTest.php @@ -0,0 +1,127 @@ +actingAs($user, 'sales'). + * + * SharesSupplierPdo обязателен: модели sales_sms_* прибиты к pgsql_supplier + * (отдельный PDO), без шаринга DatabaseTransactions откатывал бы только + * соединение по умолчанию — записи текли бы из теста в тест (правило 10 плана). + */ +uses(DatabaseTransactions::class, SharesSupplierPdo::class); + +// ── helpers (уникальный префикс sms_, чтобы не столкнуться с ads_* соседнего файла) ── +// +// SalesUser::factory() в проекте НЕТ (правило 11 плана) — заводим через create() +// с uniqid(), как в AdAudienceScreenTest.php. + +function sms_head(): SalesUser +{ + return SalesUser::create([ + 'name' => 'Начальник '.uniqid(), + 'email' => 'smshead'.uniqid().'@sales.local', + 'password' => bcrypt('secret'), + 'role' => 'head', + 'is_active' => true, + ]); +} + +function sms_manager(): SalesUser +{ + return SalesUser::create([ + 'name' => 'Менеджер '.uniqid(), + 'email' => 'smsmgr'.uniqid().'@sales.local', + 'password' => bcrypt('secret'), + 'role' => 'manager', + 'is_active' => true, + ]); +} + +it('менеджеру раздел закрыт', function () { + $manager = sms_manager(); + + $this->actingAs($manager, 'sales') + ->getJson('/api/sales/sms/campaigns') + ->assertStatus(403); +}); + +it('начальник видит список рассылок', function () { + $head = sms_head(); + + $this->actingAs($head, 'sales') + ->getJson('/api/sales/sms/campaigns') + ->assertOk() + ->assertJsonStructure(['campaigns', 'sandbox']); +}); + +it('предпросмотр показывает цену и причины пропуска до отправки', function () { + $head = sms_head(); + + $this->actingAs($head, 'sales') + ->postJson('/api/sales/sms/preview', [ + 'body' => str_repeat('а', 80), + 'phones' => ['79990000001'], + ]) + ->assertOk() + ->assertJsonStructure(['segments', 'sendable_count', 'skipped', 'estimated_cost_kopecks']) + ->assertJsonPath('segments', 2); +}); + +it('не создаёт рассылку с пустым текстом', function () { + $head = sms_head(); + + $this->actingAs($head, 'sales') + ->postJson('/api/sales/sms/campaigns', ['title' => 'Пустая', 'body' => '', 'phones' => ['79990000001']]) + ->assertStatus(422); +}); + +// ── Task 10.5: номера отмеченных фирм ───────────────────────────────────────── +// +// Начальник отмечает фирмы галочками, а не переписывает 177 номеров руками. +// POST /api/sales/sms/recipients — по списку id фирм отдаёт их живые номера. + +it('отдаёт номера отмеченных фирм', function () { + $firm = SalesAdAudienceFirm::create(['firm_name' => 'ООО Ромашка', 'firm_inn' => '2452108518']); + + SalesAdAudiencePhone::create([ + 'phone' => '79990000010', 'firm_id' => $firm->id, 'firm_name' => 'ООО Ромашка', + 'expires_at' => now()->addDays(30), 'state' => 'active', + 'operator' => 'МТС', 'phone_type' => 'mobile', + ]); + + $this->actingAs(sms_head(), 'sales') + ->postJson('/api/sales/sms/recipients', ['firm_ids' => [$firm->id]]) + ->assertOk() + ->assertJsonPath('phones.0.phone', '79990000010') + ->assertJsonPath('phones.0.firm_name', 'ООО Ромашка'); +}); + +it('не отдаёт погашенные номера', function () { + $firm = SalesAdAudienceFirm::create(['firm_name' => 'ООО Ушедшая', 'firm_inn' => '2452108519']); + + SalesAdAudiencePhone::create([ + 'phone' => '79990000011', 'firm_id' => $firm->id, + 'expires_at' => now()->addDays(30), 'state' => 'active', + 'removed_at' => now(), 'operator' => 'МТС', 'phone_type' => 'mobile', + ]); + + $this->actingAs(sms_head(), 'sales') + ->postJson('/api/sales/sms/recipients', ['firm_ids' => [$firm->id]]) + ->assertOk() + ->assertJsonPath('phones', []); +}); diff --git a/app/tests/Feature/Sales/SendSmsCampaignJobTest.php b/app/tests/Feature/Sales/SendSmsCampaignJobTest.php new file mode 100644 index 00000000..176dc5d1 --- /dev/null +++ b/app/tests/Feature/Sales/SendSmsCampaignJobTest.php @@ -0,0 +1,118 @@ + — PHPStan не понимает привязку $this + * в Pest (тот же приём, что sms_phone() в SmsRecipientSelectorTest). + * + * @param array $phones + */ +function sms_campaign_with_phones(array $phones): SalesSmsCampaign +{ + $sender = SalesSmsSender::create([ + 'name' => 'liderra.ru', 'provider_key' => 'fake', 'status' => 'active', + ]); + + foreach ($phones as $p) { + SalesAdAudiencePhone::create([ + 'phone' => $p, 'expires_at' => now()->addDays(30), + 'state' => 'active', 'operator' => 'МТС', 'phone_type' => 'mobile', + ]); + } + + return SalesSmsCampaign::create([ + 'title' => 'Прогрев', 'body' => 'Здравствуйте!', 'created_by' => 1, + 'sender_id' => $sender->id, 'status' => SalesSmsCampaign::STATUS_QUEUED, + ]); +} + +it('отправляет каждому годному номеру и закрывает кампанию', function () { + $campaign = sms_campaign_with_phones(['79990000001', '79990000002']); + + (new SendSmsCampaignJob($campaign->id, ['79990000001', '79990000002']))->handle(); + + $campaign->refresh(); + expect($campaign->status)->toBe(SalesSmsCampaign::STATUS_DONE) + ->and($campaign->sent_count)->toBe(2) + ->and($campaign->messages)->toHaveCount(2); +}); + +it('пропущенные номера попадают в журнал с причиной, но не в отправку', function () { + $campaign = sms_campaign_with_phones(['79990000003', '79990000004']); + SalesSmsOptout::create(['phone' => '79990000004', 'reason' => 'manual', 'created_at' => now()]); + + (new SendSmsCampaignJob($campaign->id, ['79990000003', '79990000004']))->handle(); + + $campaign->refresh(); + expect($campaign->sent_count)->toBe(1) + ->and($campaign->skipped_count)->toBe(1); + + $skipped = SalesSmsMessage::where('campaign_id', $campaign->id) + ->where('phone', '79990000004')->first(); + + expect($skipped->status)->toBe(SalesSmsMessage::SKIP_OPTOUT) + ->and($skipped->cost_kopecks)->toBe(0); +}); + +it('не стартует без согласованного имени отправителя', function () { + $campaign = sms_campaign_with_phones(['79990000005']); + $campaign->sender->update(['status' => 'pending']); + + (new SendSmsCampaignJob($campaign->id, ['79990000005']))->handle(); + + $campaign->refresh(); + expect($campaign->status)->toBe(SalesSmsCampaign::STATUS_FAILED) + ->and($campaign->last_error)->toContain('имя отправителя') + ->and($campaign->messages)->toHaveCount(0); +}); + +it('повторный запуск не создаёт вторых сообщений', function () { + $campaign = sms_campaign_with_phones(['79990000006']); + + (new SendSmsCampaignJob($campaign->id, ['79990000006']))->handle(); + (new SendSmsCampaignJob($campaign->id, ['79990000006']))->handle(); + + expect(SalesSmsMessage::where('campaign_id', $campaign->id)->count())->toBe(1); +}); + +it('при нехватке денег у клиента не уходит ни одной СМС', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '1.00']); + $campaign = sms_campaign_with_phones(['79990000007']); + $campaign->update(['tenant_id' => $tenant->id, 'estimated_cost_kopecks' => 50000]); + + (new SendSmsCampaignJob($campaign->id, ['79990000007']))->handle(); + + $campaign->refresh(); + expect($campaign->status)->toBe(SalesSmsCampaign::STATUS_FAILED) + ->and($campaign->sent_count)->toBe(0) + ->and($campaign->last_error)->toContain('Не хватило денег'); + + $row = SalesSmsMessage::where('campaign_id', $campaign->id)->first(); + expect($row->status)->toBe(SalesSmsMessage::SKIP_NO_FUNDS); + + // Деньги не тронуты: списания не было, потому что его не хватало. + expect((string) $tenant->refresh()->balance_rub)->toBe('1.00'); +}); diff --git a/app/tests/Feature/Sales/SmsChargeServiceTest.php b/app/tests/Feature/Sales/SmsChargeServiceTest.php new file mode 100644 index 00000000..9f83e987 --- /dev/null +++ b/app/tests/Feature/Sales/SmsChargeServiceTest.php @@ -0,0 +1,82 @@ + 'Своя', 'body' => 'текст', 'created_by' => 1, 'tenant_id' => null, + ]); + + (new SmsChargeService)->charge($campaign, '150.00'); + + expect($campaign->refresh()->balance_transaction_id)->toBeNull(); +}); + +it('списывает с баланса клиента и пишет операцию', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + $campaign = SalesSmsCampaign::create([ + 'title' => 'Клиентская', 'body' => 'текст', 'created_by' => 1, 'tenant_id' => $tenant->id, + ]); + + (new SmsChargeService)->charge($campaign, '150.00'); + + expect((string) $tenant->refresh()->balance_rub)->toBe('850.00') + ->and($campaign->refresh()->balance_transaction_id)->not->toBeNull(); + + $tx = BalanceTransaction::find($campaign->balance_transaction_id); + expect($tx->type)->toBe(BalanceTransaction::TYPE_SMS_CHARGE) + ->and($tx->amount_rub)->toBe('-150.00'); +}); + +it('повторный запуск не списывает второй раз', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + $campaign = SalesSmsCampaign::create([ + 'title' => 'Повтор', 'body' => 'текст', 'created_by' => 1, 'tenant_id' => $tenant->id, + ]); + + $service = new SmsChargeService; + $service->charge($campaign, '150.00'); + $service->charge($campaign->refresh(), '150.00'); + + expect((string) $tenant->refresh()->balance_rub)->toBe('850.00'); +}); + +it('не списывает второй раз, даже если пометка на кампании потерялась', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + $campaign = SalesSmsCampaign::create([ + 'title' => 'Обрыв', 'body' => 'текст', 'created_by' => 1, 'tenant_id' => $tenant->id, + ]); + + (new SmsChargeService)->charge($campaign, '150.00'); + + // Имитируем обрыв: деньги списаны, а пометка на кампании не доехала. + $campaign->refresh()->update(['balance_transaction_id' => null]); + + (new SmsChargeService)->charge($campaign->refresh(), '150.00'); + + // Списание должно остаться ОДНО. + expect((string) $tenant->refresh()->balance_rub)->toBe('850.00'); +}); + +it('при нехватке денег не списывает ничего', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '10.00']); + $campaign = SalesSmsCampaign::create([ + 'title' => 'Бедная', 'body' => 'текст', 'created_by' => 1, 'tenant_id' => $tenant->id, + ]); + + expect(fn () => (new SmsChargeService)->charge($campaign, '150.00')) + ->toThrow(InsufficientBalanceException::class); + + expect((string) $tenant->refresh()->balance_rub)->toBe('10.00') + ->and($campaign->refresh()->balance_transaction_id)->toBeNull(); +}); diff --git a/app/tests/Feature/Sales/SmsModelsTest.php b/app/tests/Feature/Sales/SmsModelsTest.php new file mode 100644 index 00000000..bc0fcbad --- /dev/null +++ b/app/tests/Feature/Sales/SmsModelsTest.php @@ -0,0 +1,48 @@ +getConnectionName())->toBe('pgsql_supplier'); + } +}); + +it('связывает рассылку с её сообщениями', function () { + $campaign = SalesSmsCampaign::create([ + 'title' => 'Тест', 'body' => 'текст', 'created_by' => 1, + ]); + + SalesSmsMessage::create([ + 'campaign_id' => $campaign->id, 'phone' => '79990000001', 'status' => 'pending', + ]); + + expect($campaign->refresh()->messages)->toHaveCount(1); +}); + +it('сохраняет оператора и тип у номера прогрева', function () { + $phone = SalesAdAudiencePhone::create([ + 'phone' => '79990000002', + 'expires_at' => now()->addDays(30), + 'operator' => 'ВымпелКом', + 'phone_type' => 'mobile', + ]); + + expect($phone->refresh()->operator)->toBe('ВымпелКом') + ->and($phone->phone_type)->toBe('mobile'); +}); diff --git a/app/tests/Feature/Sales/SmsRecipientSelectorTest.php b/app/tests/Feature/Sales/SmsRecipientSelectorTest.php new file mode 100644 index 00000000..7c50d25c --- /dev/null +++ b/app/tests/Feature/Sales/SmsRecipientSelectorTest.php @@ -0,0 +1,93 @@ + — PHPStan не понимает + * привязку $this в Pest и ругается на каждое обращение. */ +function sms_selector(): SmsRecipientSelector +{ + return new SmsRecipientSelector(new SmsRouter([new FakeSmsProvider])); +} + +function sms_phone(string $phone, array $overrides = []): SalesAdAudiencePhone +{ + return SalesAdAudiencePhone::create(array_merge([ + 'phone' => $phone, + 'expires_at' => now()->addDays(30), + 'state' => 'active', + 'operator' => 'МТС', + 'phone_type' => 'mobile', + ], $overrides)); +} + +it('годный номер попадает в отправку', function () { + sms_phone('79990000001'); + + $plan = sms_selector()->build(['79990000001']); + + expect($plan->sendable)->toHaveCount(1) + ->and($plan->skipped)->toBeEmpty(); +}); + +it('номер из стоп-листа не отправляется никогда', function () { + sms_phone('79990000002'); + SalesSmsOptout::create(['phone' => '79990000002', 'reason' => 'manual', 'created_at' => now()]); + + $plan = sms_selector()->build(['79990000002']); + + expect($plan->sendable)->toBeEmpty() + ->and($plan->skipped)->toBe(['79990000002' => SalesSmsMessage::SKIP_OPTOUT]); +}); + +it('городской номер не отправляется — деньги сгорели бы впустую', function () { + sms_phone('79990000003', ['phone_type' => 'landline']); + + $plan = sms_selector()->build(['79990000003']); + + expect($plan->skipped)->toBe(['79990000003' => SalesSmsMessage::SKIP_NOT_MOBILE]); +}); + +it('погашенный номер не отправляется', function () { + sms_phone('79990000004', ['removed_at' => now()]); + + $plan = sms_selector()->build(['79990000004']); + + expect($plan->skipped)->toBe(['79990000004' => SalesSmsMessage::SKIP_INACTIVE]); +}); + +it('дубль внутри одного запуска считается один раз', function () { + sms_phone('79990000005'); + + $plan = sms_selector()->build(['79990000005', '79990000005']); + + expect($plan->sendable)->toHaveCount(1); +}); + +it('без подходящего провайдера номер помечается без маршрута', function () { + sms_phone('79990000006'); + $selector = new SmsRecipientSelector(new SmsRouter([])); + + $plan = $selector->build(['79990000006']); + + expect($plan->skipped)->toBe(['79990000006' => SalesSmsMessage::SKIP_NO_ROUTE]); +}); diff --git a/app/tests/Feature/Sales/SmsSchemaTest.php b/app/tests/Feature/Sales/SmsSchemaTest.php new file mode 100644 index 00000000..ef57272c --- /dev/null +++ b/app/tests/Feature/Sales/SmsSchemaTest.php @@ -0,0 +1,60 @@ +getSchemaBuilder()->hasTable($table))->toBeTrue("нет таблицы {$table}"); + } +}); + +it('заводит tenant_id во всех таблицах СМС — задел под клиентов', function () { + $db = DB::connection('pgsql_supplier')->getSchemaBuilder(); + + foreach (['sales_sms_senders', 'sales_sms_campaigns', 'sales_sms_optouts'] as $table) { + expect($db->hasColumn($table, 'tenant_id'))->toBeTrue("нет tenant_id в {$table}"); + } +}); + +it('добавляет оператора и тип номера в таблицу номеров прогрева', function () { + $db = DB::connection('pgsql_supplier')->getSchemaBuilder(); + + expect($db->hasColumn('sales_ad_audience_phones', 'operator'))->toBeTrue(); + expect($db->hasColumn('sales_ad_audience_phones', 'phone_type'))->toBeTrue(); +}); + +it('запрещает два одинаковых номера внутри одной рассылки', function () { + $db = DB::connection('pgsql_supplier'); + + $campaignId = $db->table('sales_sms_campaigns')->insertGetId([ + 'title' => 'Проверка', 'body' => 'текст', 'status' => 'draft', 'created_by' => 1, + ]); + + $row = [ + 'campaign_id' => $campaignId, 'phone' => '79990000001', + 'status' => 'pending', 'segments' => 1, 'cost_kopecks' => 0, + ]; + + $db->table('sales_sms_messages')->insert($row); + + expect(fn () => $db->table('sales_sms_messages')->insert($row)) + ->toThrow(QueryException::class); +}); diff --git a/app/tests/Feature/Services/SnapshotHelperTimeOfDayTest.php b/app/tests/Feature/Services/SnapshotHelperTimeOfDayTest.php new file mode 100644 index 00000000..3c9c8bf2 --- /dev/null +++ b/app/tests/Feature/Services/SnapshotHelperTimeOfDayTest.php @@ -0,0 +1,54 @@ +create(['balance_leads' => 100, 'balance_rub' => '1000.00']); + $sp = SupplierProject::query()->create([ + 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r.ru', + 'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok', + ]); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, 'is_active' => true, + 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, + ]); + linkProjectToSupplier($project, $sp); + createRoutingSnapshotFromProject($project); + + $matched = app(LeadRouter::class)->matchEligibleProjects($sp); + + expect($matched)->toHaveCount(1) + ->and($matched->first()->id)->toBe($project->id); +}); diff --git a/app/tests/Frontend/SalesSmsView.spec.ts b/app/tests/Frontend/SalesSmsView.spec.ts new file mode 100644 index 00000000..15128fd3 --- /dev/null +++ b/app/tests/Frontend/SalesSmsView.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const view = readFileSync( + resolve(__dirname, '../../resources/js/views/sales/SalesSmsView.vue'), + 'utf-8', +); + +describe('Экран «Прогрев СМС»', () => { + it('показывает цену до отправки', () => { + expect(view).toContain('estimated_cost_kopecks'); + }); + + it('помечает режим песочницы явно — «отправлено» нельзя спутать с настоящей отправкой', () => { + expect(view).toMatch(/песочниц/i); + }); + + it('блокирует кнопку без согласованного имени отправителя', () => { + expect(view).toMatch(/:disabled/); + expect(view).toMatch(/имя отправителя/i); + }); + + it('показывает счётчик сегментов', () => { + expect(view).toContain('segments'); + }); + + it('показывает причины пропуска номеров', () => { + expect(view).toContain('skipped'); + }); + + // Task 10.5: у начальника 99 фирм и 177 номеров — руками их не переписывают. + it('получателей отмечают галочками, а не вводят руками', () => { + expect(view).toContain('v-checkbox'); + expect(view).toContain('fetchSmsRecipients'); + }); + + it('ходит в сервер через общую прослойку, а не напрямую', () => { + expect(view).not.toContain('axios.post'); + expect(view).not.toContain('axios.get'); + }); +}); diff --git a/app/tests/Pest.php b/app/tests/Pest.php index 03424067..216db9f5 100644 --- a/app/tests/Pest.php +++ b/app/tests/Pest.php @@ -39,6 +39,10 @@ pest()->extend(TestCase::class)->in('Browser'); // (в т.ч. FakeCompetitorAgentTest — резолвит CompetitorAgent через провайдер). pest()->extend(TestCase::class)->in('Unit/Autopodbor'); +// Unit/Sms: провайдеры пишут в журнал (Log::), а без контейнера у фасада нет корня. +// БД этим тестам не нужна — только мотор Laravel. +pest()->extend(TestCase::class)->in('Unit/Sms'); + /* |-------------------------------------------------------------------------- | Expectations @@ -191,8 +195,10 @@ function linkProjectToSupplier(Project $project, SupplierProject $supplier): voi * которым нужен только факт «маршрутизация возможна», а не сам snapshot * mechanism. * - * Активная дата по умолчанию — сегодняшняя МСК (до 21:00 МСК). Передайте - * `$date` явно, если тест использует `Carbon::setTestNow` с другой датой. + * Активная дата по умолчанию — зеркало LeadRouter::activeSnapshotDate() через + * testActiveSnapshotDate() (после 21:00 МСК завтра, до — сегодня), поэтому снимок + * всегда ложится на дату, которую спросит роутер, в любой час. Передайте `$date` + * явно, если тест использует `Carbon::setTestNow` с другой датой. * * NB: signal_type/signal_identifier берутся ЯВНО из аргументов, а не из * `$project->signal_type` — на Windows-native PG факториальный override @@ -253,6 +259,21 @@ function insertSnapshotForTomorrow( ]); } +/** + * Активная дата слепка — зеркало LeadRouter::activeSnapshotDate(): после 21:00 МСК + * завтра, до — сегодня (вечерний переворот заливки). Помощник обязан класть снимок + * на ТУ ЖЕ дату, которую спросит роутер, иначе вечерние прогоны (после 21:00 МСК) + * не находят снимок и весь пласт тестов доставки падает. См. SnapshotHelperTimeOfDayTest. + */ +function testActiveSnapshotDate(): string +{ + $msk = Carbon::now('Europe/Moscow'); + + return $msk->hour >= 21 + ? $msk->copy()->addDay()->toDateString() + : $msk->toDateString(); +} + function createRoutingSnapshotFromProject( Project $project, ?string $date = null, @@ -262,7 +283,7 @@ function createRoutingSnapshotFromProject( string $regions = '{}', ): void { DB::table('project_routing_snapshots')->insert([ - 'snapshot_date' => $date ?? Carbon::today('Europe/Moscow')->toDateString(), + 'snapshot_date' => $date ?? testActiveSnapshotDate(), 'project_id' => $project->id, 'tenant_id' => $project->tenant_id, 'daily_limit' => $dailyLimit ?? (int) ($project->effective_daily_limit_today ?? $project->daily_limit_target), diff --git a/app/tests/Unit/Sms/FakeSmsProviderTest.php b/app/tests/Unit/Sms/FakeSmsProviderTest.php new file mode 100644 index 00000000..60503c5e --- /dev/null +++ b/app/tests/Unit/Sms/FakeSmsProviderTest.php @@ -0,0 +1,27 @@ +servesOperators())->toBe(['*']); +}); + +it('заглушка ничего не стоит — деньги не должны утекать в песочнице', function () { + expect((new FakeSmsProvider)->priceKopecks('ВымпелКом'))->toBe(0); +}); + +it('заглушка возвращает опознаваемый идентификатор', function () { + $result = (new FakeSmsProvider)->send(new SmsOutgoing( + phone: '79990000001', + body: 'проверка', + senderName: 'liderra.ru', + operator: 'МТС', + segments: 1, + )); + + expect($result->providerMessageId)->toStartWith('fake-') + ->and($result->costKopecks)->toBe(0); +}); diff --git a/app/tests/Unit/Sms/SmsRouterTest.php b/app/tests/Unit/Sms/SmsRouterTest.php new file mode 100644 index 00000000..a7961c84 --- /dev/null +++ b/app/tests/Unit/Sms/SmsRouterTest.php @@ -0,0 +1,81 @@ +k; + } + + public function servesOperators(): array + { + return $this->ops; + } + + public function priceKopecks(string $operator): int + { + return $this->price; + } + + public function send(SmsOutgoing $m): SmsSendResult + { + return new SmsSendResult('x', 1, $this->price, CarbonImmutable::now()); + } + }; +} + +it('билайновский номер уходит через Билайн, а не через дорогого универсала', function () { + $router = new SmsRouter([ + fakeProviderFor('mts', ['*'], 990), + fakeProviderFor('beeline', ['ВымпелКом'], 475), + ]); + + expect($router->pick('ВымпелКом')?->key())->toBe('beeline'); +}); + +it('чужой оператор уходит через универсала', function () { + $router = new SmsRouter([ + fakeProviderFor('mts', ['*'], 990), + fakeProviderFor('beeline', ['ВымпелКом'], 475), + ]); + + expect($router->pick('МегаФон')?->key())->toBe('mts'); +}); + +it('при неизвестном операторе берёт универсала', function () { + $router = new SmsRouter([fakeProviderFor('mts', ['*'], 990)]); + + expect($router->pick('')?->key())->toBe('mts'); +}); + +it('без подходящего провайдера возвращает null — номер будет пропущен, деньги целы', function () { + $router = new SmsRouter([fakeProviderFor('beeline', ['ВымпелКом'], 475)]); + + expect($router->pick('МегаФон'))->toBeNull(); +}); + +it('из двух подходящих выбирает дешёвого', function () { + $router = new SmsRouter([ + fakeProviderFor('dear', ['МТС'], 900), + fakeProviderFor('cheap', ['МТС'], 503), + ]); + + expect($router->pick('МТС')?->key())->toBe('cheap'); +}); diff --git a/app/tests/Unit/Sms/SmsSegmentCalculatorTest.php b/app/tests/Unit/Sms/SmsSegmentCalculatorTest.php new file mode 100644 index 00000000..8184f9c2 --- /dev/null +++ b/app/tests/Unit/Sms/SmsSegmentCalculatorTest.php @@ -0,0 +1,29 @@ +segments(str_repeat('а', 70)))->toBe(1); +}); + +it('71 символ — уже два сегмента', function () { + expect((new SmsSegmentCalculator)->segments(str_repeat('а', 71)))->toBe(2); +}); + +it('134 символа — два сегмента, 135 — три', function () { + $calc = new SmsSegmentCalculator; + + expect($calc->segments(str_repeat('а', 134)))->toBe(2) + ->and($calc->segments(str_repeat('а', 135)))->toBe(3); +}); + +it('пустой текст — один сегмент, а не ноль', function () { + expect((new SmsSegmentCalculator)->segments(''))->toBe(1); +}); + +it('считает кириллицу посимвольно, а не побайтово', function () { + // «привет» — 6 символов, но 12 байт. Побайтовый счёт дал бы неверный результат. + expect((new SmsSegmentCalculator)->segments(str_repeat('привет', 11)))->toBe(1); +}); diff --git a/cspell-words.txt b/cspell-words.txt index 991ee704..946dc87a 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -2389,3 +2389,19 @@ thr THR svezhest skorost +avtopodbor +Воркстри +Мультиклиентность +воркстри +межтенантно +межтенантного +межтенантный +опознавалки +синкает +синкана +синканные +синкано +скипают +скоупы +харднинг +яндексовом diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 5838160f..fb1dd9b3 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -8,6 +8,68 @@ > параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор), > поэтому при сведении они перенумерованы. Содержание не менялось. +## v8.81 (2026-07-23) — Модуль «Прогрев СМС»: четыре таблицы рассылки + оператор у номеров + +Начальник отдела продаж отправляет СМС по отмеченным фирмам прогрева: свой текст, +видимая цена до отправки, журнал после. Под это заводится четыре таблицы модуля и +две колонки у уже существующей таблицы номеров прогрева. + +Миграция: `app/database/migrations/2026_07_23_100000_create_sales_sms_tables.php`. +План: `docs/superpowers/plans/2026-07-20-sms-progrev-modul.md` Task 1. +Спека: `docs/superpowers/specs/2026-07-20-sms-progrev-modul-design.md`. + +**Добавлено — таблицы (все SaaS-level, без RLS, как остальные `sales_*`):** + +- `sales_sms_senders` — имя отправителя, регистрируется у каждого провайдера + отдельно и проходит модерацию оператора. `status` с CHECK + (`draft`/`pending`/`active`/`rejected`) — пока не `active`, рассылка не стартует. + Уникальный индекс `uniq_sms_sender` на `(COALESCE(tenant_id, 0), name, provider_key)`. +- `sales_sms_campaigns` — рассылка: текст, счётчики (`planned`/`sent`/`failed`/ + `skipped`), оценочная и фактическая стоимость в копейках, ссылка на списание + (`balance_transaction_id`). `status` с CHECK (`draft`/`queued`/`sending`/`done`/ + `failed`/`canceled`). FK `sender_id → sales_sms_senders(id)`. +- `sales_sms_messages` — строка журнала на один номер. FK `campaign_id → + sales_sms_campaigns(id) ON DELETE CASCADE`. Уникальный индекс + `uniq_sms_message_per_campaign` на `(campaign_id, phone)` — **дубль номера внутри + одной рассылки запрещён на уровне БД, а не только в коде**. Рабочий индекс + `idx_sms_messages_campaign` на `(campaign_id, status)`. +- `sales_sms_optouts` — стоп-лист. `reason` с CHECK (`manual`/`reply_stop`/ + `complaint`/`operator`). Уникальный индекс `uniq_sms_optout` на + `(COALESCE(tenant_id, 0), phone)`. + +**Мультиклиентность:** `tenant_id BIGINT NULL` заведён с первого дня в +`senders`/`campaigns`/`optouts` как задел под клиентскую версию, `NULL` = «Лидерра +сама». Внешнего ключа на `tenants` **нет намеренно** — `tenants` живут на другом +соединении (`pgsql`), JOIN между соединениями невозможен, разрешение в PHP. + +**Добавлено — колонки в `sales_ad_audience_phones`:** + +- `operator VARCHAR(30) NULL` — оператор номера. +- `phone_type VARCHAR(12) NULL` — тип номера (мобильный/городской). + +Оба значения приходят из «Поиска клиентов» и уже оплачены ДаДате — второй раз за +них не платим, поэтому храним рядом с номером. + +**GRANT'ы:** выданы обеим ролям admin-db (`crm_admin_user`/`crm_supplier_worker`) +внутри миграции, через `DO $$ ... pg_roles`-guard, как в +`create_sales_ad_audience_tables`. `SELECT, INSERT, UPDATE` на `senders`/ +`campaigns`/`messages`; `SELECT, INSERT, DELETE` на `optouts` (из стоп-листа +номер можно убрать); `USAGE, SELECT ON ALL SEQUENCES`. Новые колонки +`sales_ad_audience_phones` наследуют привилегии таблицы. **Тестам гранты +невидимы** — тесты идут под ролью с полным доступом, забытый GRANT дал бы зелёные +тесты и падение на правах на бою. + +**Проверено на `liderra_testing`:** миграция вверх/вниз/вверх — все три прогона +`DONE`, без ошибок про висящие FK и индексы (`DROP TABLE ... CASCADE` в обратном +порядке зависимостей, колонки — `DROP COLUMN IF EXISTS`). Тест +`app/tests/Feature/Sales/SmsSchemaTest.php` — 4 теста, 10 утверждений, зелёный до +и после roundtrip; уникальность `(campaign_id, phone)` проверена реальным +повторным `INSERT` (ожидается `QueryException`). + +**Структурно:** +4 таблицы, +4 уникальных индекса, +1 рабочий индекс, +3 CHECK, ++2 FK; `sales_ad_audience_phones` +2 колонки. RLS/функций/триггеров/партиций без +изменений. + ## v8.80 (2026-07-22) — Три площадки прогрева вместо двух: Яндекс / ВК / МТС Поле `channels` (`yandex`|`vk`|`both`) не растягивается на третью площадку МТС — @@ -618,7 +680,7 @@ CHECK берёт кратковременный ACCESS EXCLUSIVE на родит `survivor_id`→`autopodbor_competitors` SET NULL (история переживает удаление выжившей/сотрудника). Индекс `(tenant_id, created_at)`, per-tenant RLS `tenant_isolation` (ENABLE+FORCE). Миграция 2026_07_05_140000. -**Идемпотентность:** `elements`/`runs_progress`/`runs_result` доguard-ены `Schema::hasColumn` early-return; +**Идемпотентность:** `elements`/`runs_progress`/`runs_result` снабжены guard-ом `Schema::hasColumn` early-return; `add_box`/`add_phones`/`add_phone_type`/`add_where_found` — уже с `hasColumn`; create-миграции — `to_regclass`. **Счётчики (для autopodbor-блока):** autopodbor_competitors +3 колонки/+1 индекс/+1 CHECK; autopodbor_runs +2 колонки; diff --git a/docs/superpowers/plans/2026-07-20-sms-progrev-modul.md b/docs/superpowers/plans/2026-07-20-sms-progrev-modul.md new file mode 100644 index 00000000..dcace648 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-sms-progrev-modul.md @@ -0,0 +1,2709 @@ +# Модуль «Прогрев СМС» — план работ + +> **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:** начальник отдела продаж отправляет СМС по отмеченным фирмам прогрева: свой текст, видимая цена до отправки, журнал после. + +**Architecture:** разъём провайдеров (`SmsProvider`) + маршрутизация по оператору номера + заглушка `FakeSmsProvider` для работы до согласования имени отправителя. Списание копирует готовый приём `AutopodborChargeService`. Мультиклиентность — колонкой `tenant_id` во всех новых таблицах с первого дня. + +**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 (соединение `pgsql_supplier`, без RLS), Pest 4, Vue 3 + Vuetify 3, Vitest. + +**Спека:** [docs/superpowers/specs/2026-07-20-sms-progrev-modul-design.md](../specs/2026-07-20-sms-progrev-modul-design.md) + +--- + +## Общие правила для исполнителя + +1. **Соединение.** Все новые таблицы и модели — `pgsql_supplier`. В моделях обязательно + `protected $connection = 'pgsql_supplier';`. JOIN с `tenants`/`sales_prospects` + **невозможен** — они на другом соединении, сопоставление в PHP. +2. **Тесты.** Backend — `composer test`, фронт — `npm run test:vue`. Оба из `app/`. +3. **Защиту проверяем вырезанием защиты.** Где в задаче написано «проверь вырезанием» — + временно убери проверку, убедись, что тест покраснел, верни на место. +4. **Секреты только в `.env`.** Ни один ключ провайдера не попадает в репозиторий. +5. **Коммит — с явными путями файлов**, без `git add .` и без `-A`. +6. **Живая отправка на бой — только с разрешения владельца.** Задачи 1–11 боевого + ничего не касаются. +7. **Перед первым тестом проверь, что фабрики есть.** В тестах используются + `SalesUser::factory()` и `Tenant::factory()`. Если какой-то из них нет — + заводи пользователя через `SalesUser::create([...])` с полями `role`, `email`, + `password`, а не выдумывай фабрику. Посмотри, как это сделано в соседнем + `app/tests/Feature/Sales/AdAudienceScreenTest.php`, и повтори тот же приём. +9. 🪤 **Миграции сами не применяются — база тестов постоянная.** В `app/tests/Pest.php` + строка 29 `->use(RefreshDatabase::class)` **закомментирована**, `liderra_testing` + живёт между прогонами. Написать миграцию мало: тест останется красным ровно с той же + ошибкой, и это легко принять за «код не работает». После создания миграции выполни: + + ```bash + cd app && DB_DATABASE=liderra_testing APP_ENV=testing php artisan migrate --force \ + --path=database/migrations/<имя_файла_миграции>.php + ``` + + Найдено при исполнении Task 1. + +10. 🪤 **Каждый Feature-тест по `sales_*` ОБЯЗАН начинаться с отката.** Прямое следствие + правила 9: без этого тест зелёный **ровно один раз**, а со второго прогона падает на + `SQLSTATE[23505] Unique violation` — и это легко принять за поломку кода. В проекте + для этого есть готовый приём, использовать только его: + + ```php + use Illuminate\Foundation\Testing\DatabaseTransactions; + use Tests\Concerns\SharesSupplierPdo; + + uses(DatabaseTransactions::class, SharesSupplierPdo::class); + ``` + + Второй трейт обязателен: модели `sales_*` прибиты к `pgsql_supplier` (отдельный PDO), + и без шаринга откатывалось бы только соединение по умолчанию. Образец — + `app/tests/Feature/Sales/AdAudienceScreenTest.php:27`. + + **Никакой ручной уборки через `->delete()` в начале теста** — это лечит симптом и + разваливается при параллельном прогоне. Найдено при исполнении Task 2. + +12. 🪤 **Unit-тесты идут БЕЗ мотора Laravel.** `app/tests/Pest.php` подключает `TestCase` + только к `Feature`, `Browser` и явно к `Unit/Autopodbor`. Если класс под тестом + дёргает фасад (`Log::`, `Cache::`, `config()`), Unit-тест упадёт с «A facade root + has not been set» — причём только тот тест, который реально доходит до вызова. + Для СМС это уже решено строкой `pest()->extend(TestCase::class)->in('Unit/Sms');` + в `Pest.php` (добавлена при исполнении Task 3 по образцу `Unit/Autopodbor`). + Новые Unit-каталоги — тем же приёмом, а **не** выкидыванием `Log::` из боевого кода + и не глушением через `Log::spy()`. + +13. 🔴 **Если у ручки есть проверка на входе — тест обязан идти ЧЕРЕЗ HTTP, а не мимо.** + Самая дорогая находка этой работы. Task 11 менял формат передачи номеров, тест звал + `(new AdAudienceIntake)->ingest([...])` напрямую и был зелёный, а живой путь возвращал + **422 и не записывал ничего**: контроллер требовал `phones.*` строкой по regex. + Проверяли замок, заходя через окно, — дыра была в двери. + + Правило: меняешь то, что приходит снаружи, — тест шлёт `postJson()` на настоящий + маршрут с настоящими заголовками. Прямой вызов сервиса допустим только ДОПОЛНИТЕЛЬНО, + не вместо. + + Тот же класс, что дефект замка в Task 7: **тесты зелёные, на бою не работает.** + Оба раза причина была в плане, оба раза нашёл исполнитель. + +11. **Пользователи заводятся через `create()`, а не фабрикой.** `SalesUser::factory()` + в проекте нет. Приём — как в `AdAudienceScreenTest.php`: `SalesUser::create([...])` + с `uniqid()` в имени/почте, чтобы не ловить конфликт уникальности. Это отменяет + оговорку правила 7 в пользу конкретного образца. +8. **Гранты БД невидимы тестам.** Тесты идут под ролью с полным доступом, а на бою + работает `crm_supplier_worker`. Если забыть блок GRANT в миграции, тесты будут + зелёными, а на бою раздел упадёт на правах. + +--- + +## Структура файлов + +**Создаются:** + +| Файл | Ответственность | +|---|---| +| `app/database/migrations/2026_07_23_100000_create_sales_sms_tables.php` | 4 таблицы + 2 колонки в phones | +| `app/database/migrations/2026_07_23_100100_extend_balance_transactions_type_for_sms.php` | новый тип операции `sms_charge` | +| `app/app/Models/SalesSmsSender.php` | имя отправителя | +| `app/app/Models/SalesSmsCampaign.php` | рассылка | +| `app/app/Models/SalesSmsMessage.php` | строка журнала | +| `app/app/Models/SalesSmsOptout.php` | стоп-лист | +| `app/app/Services/Sms/SmsOutgoing.php` | DTO исходящего | +| `app/app/Services/Sms/SmsSendResult.php` | DTO результата | +| `app/app/Services/Sms/SmsProvider.php` | интерфейс провайдера | +| `app/app/Services/Sms/Providers/FakeSmsProvider.php` | заглушка | +| `app/app/Services/Sms/SmsSegmentCalculator.php` | сегменты и цена | +| `app/app/Services/Sms/SmsRouter.php` | выбор провайдера по оператору | +| `app/app/Services/Sms/SmsRecipientSelector.php` | отбор получателей и причины пропуска | +| `app/app/Services/Sms/SmsChargeService.php` | списание | +| `app/app/Jobs/SendSmsCampaignJob.php` | отправка очередью | +| `app/app/Http/Controllers/Api/Sales/SalesSmsController.php` | API начальника | +| `app/resources/js/views/sales/SalesSmsView.vue` | экран | + +**Изменяются:** + +| Файл | Что | +|---|---| +| `app/app/Models/SalesAdAudiencePhone.php` | +`operator`, +`phone_type` в `$fillable` и докблок | +| `app/app/Models/BalanceTransaction.php` | +константа `TYPE_SMS_CHARGE` | +| `app/app/Services/Sales/AdAudienceIntake.php` | приём оператора и типа номера | +| `app/config/services.php` | секция `sms` | +| `app/routes/web.php` | маршруты раздела | +| `app/resources/js/router/index.ts` | маршрут экрана | +| `моя/sales-finder/salesfinder/adaudience.py` | отдавать оператора, а не голый номер | + +--- + +## Task 1: Таблицы + +**Files:** +- Create: `app/database/migrations/2026_07_23_100000_create_sales_sms_tables.php` +- Test: `app/tests/Feature/Sales/SmsSchemaTest.php` + +- [ ] **Шаг 1: Тест, который падает** + +`app/tests/Feature/Sales/SmsSchemaTest.php`: + +```php +getSchemaBuilder()->hasTable($table))->toBeTrue("нет таблицы {$table}"); + } +}); + +it('заводит tenant_id во всех таблицах СМС — задел под клиентов', function () { + $db = DB::connection('pgsql_supplier')->getSchemaBuilder(); + + foreach (['sales_sms_senders', 'sales_sms_campaigns', 'sales_sms_optouts'] as $table) { + expect($db->hasColumn($table, 'tenant_id'))->toBeTrue("нет tenant_id в {$table}"); + } +}); + +it('добавляет оператора и тип номера в таблицу номеров прогрева', function () { + $db = DB::connection('pgsql_supplier')->getSchemaBuilder(); + + expect($db->hasColumn('sales_ad_audience_phones', 'operator'))->toBeTrue(); + expect($db->hasColumn('sales_ad_audience_phones', 'phone_type'))->toBeTrue(); +}); + +it('запрещает два одинаковых номера внутри одной рассылки', function () { + $db = DB::connection('pgsql_supplier'); + + $campaignId = $db->table('sales_sms_campaigns')->insertGetId([ + 'title' => 'Проверка', 'body' => 'текст', 'status' => 'draft', 'created_by' => 1, + ]); + + $row = [ + 'campaign_id' => $campaignId, 'phone' => '79990000001', + 'status' => 'pending', 'segments' => 1, 'cost_kopecks' => 0, + ]; + + $db->table('sales_sms_messages')->insert($row); + + expect(fn () => $db->table('sales_sms_messages')->insert($row)) + ->toThrow(Illuminate\Database\QueryException::class); +}); +``` + +- [ ] **Шаг 2: Убедиться, что падает** + +Run: `cd app && composer test -- --filter=SmsSchemaTest` +Expected: FAIL — «нет таблицы sales_sms_senders». + +- [ ] **Шаг 3: Миграция** + +`app/database/migrations/2026_07_23_100000_create_sales_sms_tables.php`: + +```php +statement(<<<'SQL' + CREATE TABLE IF NOT EXISTS sales_sms_senders ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT, + name VARCHAR(11) NOT NULL, + provider_key VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft','pending','active','rejected')), + monthly_fee_kopecks INTEGER, + submitted_at TIMESTAMPTZ, + approved_at TIMESTAMPTZ, + rejected_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $db->statement(<<<'SQL' + CREATE UNIQUE INDEX IF NOT EXISTS uniq_sms_sender + ON sales_sms_senders (COALESCE(tenant_id, 0), name, provider_key) + SQL); + + $db->statement(<<<'SQL' + CREATE TABLE IF NOT EXISTS sales_sms_campaigns ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT, + sender_id BIGINT REFERENCES sales_sms_senders(id), + title VARCHAR(200) NOT NULL, + body TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft','queued','sending','done','failed','canceled')), + created_by BIGINT NOT NULL, + planned_count INTEGER NOT NULL DEFAULT 0, + sent_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + skipped_count INTEGER NOT NULL DEFAULT 0, + estimated_cost_kopecks INTEGER NOT NULL DEFAULT 0, + actual_cost_kopecks INTEGER NOT NULL DEFAULT 0, + balance_transaction_id BIGINT, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $db->statement(<<<'SQL' + CREATE TABLE IF NOT EXISTS sales_sms_messages ( + id BIGSERIAL PRIMARY KEY, + campaign_id BIGINT NOT NULL REFERENCES sales_sms_campaigns(id) ON DELETE CASCADE, + phone_id BIGINT, + phone VARCHAR(11) NOT NULL, + operator VARCHAR(30), + phone_type VARCHAR(12), + provider_key VARCHAR(20), + status VARCHAR(24) NOT NULL DEFAULT 'pending', + segments SMALLINT NOT NULL DEFAULT 1, + cost_kopecks INTEGER NOT NULL DEFAULT 0, + provider_message_id VARCHAR(64), + sent_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ, + error TEXT + ) + SQL); + + // Дубль внутри рассылки невозможен на уровне БД, а не только в коде. + $db->statement(<<<'SQL' + CREATE UNIQUE INDEX IF NOT EXISTS uniq_sms_message_per_campaign + ON sales_sms_messages (campaign_id, phone) + SQL); + + $db->statement(<<<'SQL' + CREATE INDEX IF NOT EXISTS idx_sms_messages_campaign + ON sales_sms_messages (campaign_id, status) + SQL); + + $db->statement(<<<'SQL' + CREATE TABLE IF NOT EXISTS sales_sms_optouts ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT, + phone VARCHAR(11) NOT NULL, + reason VARCHAR(30) NOT NULL DEFAULT 'manual' + CHECK (reason IN ('manual','reply_stop','complaint','operator')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $db->statement(<<<'SQL' + CREATE UNIQUE INDEX IF NOT EXISTS uniq_sms_optout + ON sales_sms_optouts (COALESCE(tenant_id, 0), phone) + SQL); + + // Оператор и тип номера приходят из Поиска клиентов (уже оплачены ДаДате). + $db->statement('ALTER TABLE sales_ad_audience_phones ADD COLUMN IF NOT EXISTS operator VARCHAR(30)'); + $db->statement('ALTER TABLE sales_ad_audience_phones ADD COLUMN IF NOT EXISTS phone_type VARCHAR(12)'); + + $db->statement(<<<'SQL' + DO $$ + DECLARE target TEXT; + BEGIN + FOREACH target IN ARRAY ARRAY['crm_admin_user','crm_supplier_worker'] LOOP + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = target) THEN + EXECUTE format('GRANT SELECT, INSERT, UPDATE ON sales_sms_senders TO %I', target); + EXECUTE format('GRANT SELECT, INSERT, UPDATE ON sales_sms_campaigns TO %I', target); + EXECUTE format('GRANT SELECT, INSERT, UPDATE ON sales_sms_messages TO %I', target); + EXECUTE format('GRANT SELECT, INSERT, DELETE ON sales_sms_optouts TO %I', target); + EXECUTE format('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO %I', target); + END IF; + END LOOP; + END $$ + SQL); + } + + public function down(): void + { + $db = DB::connection('pgsql_supplier'); + $db->statement('DROP TABLE IF EXISTS sales_sms_messages CASCADE'); + $db->statement('DROP TABLE IF EXISTS sales_sms_campaigns CASCADE'); + $db->statement('DROP TABLE IF EXISTS sales_sms_senders CASCADE'); + $db->statement('DROP TABLE IF EXISTS sales_sms_optouts CASCADE'); + $db->statement('ALTER TABLE sales_ad_audience_phones DROP COLUMN IF EXISTS operator'); + $db->statement('ALTER TABLE sales_ad_audience_phones DROP COLUMN IF EXISTS phone_type'); + } +}; +``` + +- [ ] **Шаг 4: Убедиться, что зелено** + +Run: `cd app && composer test -- --filter=SmsSchemaTest` +Expected: PASS, 4 теста. + +- [ ] **Шаг 5: Запись в журнал схемы** + +Дописать в `db/CHANGELOG_schema.md` новую версию: 4 таблицы модуля СМС + 2 колонки +в `sales_ad_audience_phones`, со ссылкой на спеку. **Правка `db/schema.sql` без записи +в CHANGELOG запрещена (§5 п.8 CLAUDE.md).** + +- [ ] **Шаг 6: Коммит** + +```bash +git add app/database/migrations/2026_07_23_100000_create_sales_sms_tables.php app/tests/Feature/Sales/SmsSchemaTest.php db/CHANGELOG_schema.md +git commit -m "feat(смс): таблицы модуля рассылки + оператор у номеров прогрева" +``` + +--- + +## Task 2: Модели + +**Files:** +- Create: `app/app/Models/SalesSmsSender.php`, `SalesSmsCampaign.php`, `SalesSmsMessage.php`, `SalesSmsOptout.php` +- Modify: `app/app/Models/SalesAdAudiencePhone.php` +- Test: `app/tests/Feature/Sales/SmsModelsTest.php` + +- [ ] **Шаг 1: Тест, который падает** + +```php +getConnectionName())->toBe('pgsql_supplier'); + } +}); + +it('связывает рассылку с её сообщениями', function () { + $campaign = SalesSmsCampaign::create([ + 'title' => 'Тест', 'body' => 'текст', 'created_by' => 1, + ]); + + SalesSmsMessage::create([ + 'campaign_id' => $campaign->id, 'phone' => '79990000001', 'status' => 'pending', + ]); + + expect($campaign->refresh()->messages)->toHaveCount(1); +}); + +it('сохраняет оператора и тип у номера прогрева', function () { + $phone = SalesAdAudiencePhone::create([ + 'phone' => '79990000002', + 'expires_at' => now()->addDays(30), + 'operator' => 'ВымпелКом', + 'phone_type' => 'mobile', + ]); + + expect($phone->refresh()->operator)->toBe('ВымпелКом') + ->and($phone->phone_type)->toBe('mobile'); +}); +``` + +- [ ] **Шаг 2: Убедиться, что падает** + +Run: `cd app && composer test -- --filter=SmsModelsTest` +Expected: FAIL — `Class "App\Models\SalesSmsSender" not found`. + +- [ ] **Шаг 3: Модели** + +`app/app/Models/SalesSmsSender.php`: + +```php + 'datetime', + 'approved_at' => 'datetime', + ]; + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE; + } +} +``` + +`app/app/Models/SalesSmsCampaign.php`: + +```php + 'datetime', + 'finished_at' => 'datetime', + ]; + + /** @return HasMany */ + public function messages(): HasMany + { + return $this->hasMany(SalesSmsMessage::class, 'campaign_id'); + } + + /** @return BelongsTo */ + public function sender(): BelongsTo + { + return $this->belongsTo(SalesSmsSender::class, 'sender_id'); + } +} +``` + +`app/app/Models/SalesSmsMessage.php`: + +```php + 'datetime', + 'delivered_at' => 'datetime', + ]; + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(SalesSmsCampaign::class, 'campaign_id'); + } +} +``` + +`app/app/Models/SalesSmsOptout.php`: + +```php + 'datetime']; +} +``` + +- [ ] **Шаг 4: Дописать поля в модель номера** + +В `app/app/Models/SalesAdAudiencePhone.php` добавить в `$fillable` строку +`'operator', 'phone_type',` и в докблок две строки: + +```php + * @property string|null $operator + * @property string|null $phone_type +``` + +- [ ] **Шаг 5: Убедиться, что зелено** + +Run: `cd app && composer test -- --filter=SmsModelsTest` +Expected: PASS, 3 теста. + +- [ ] **Шаг 6: Коммит** + +```bash +git add app/app/Models/SalesSmsSender.php app/app/Models/SalesSmsCampaign.php app/app/Models/SalesSmsMessage.php app/app/Models/SalesSmsOptout.php app/app/Models/SalesAdAudiencePhone.php app/tests/Feature/Sales/SmsModelsTest.php +git commit -m "feat(смс): модели рассылки, журнала, имён и стоп-листа" +``` + +--- + +## Task 3: Разъём провайдера и заглушка + +**Files:** +- Create: `app/app/Services/Sms/SmsOutgoing.php`, `SmsSendResult.php`, `SmsProvider.php`, `Providers/FakeSmsProvider.php` +- Modify: `app/config/services.php` +- Test: `app/tests/Unit/Sms/FakeSmsProviderTest.php` + +- [ ] **Шаг 1: Тест, который падает** + +```php +servesOperators())->toBe(['*']); +}); + +it('заглушка ничего не стоит — деньги не должны утекать в песочнице', function () { + expect((new FakeSmsProvider)->priceKopecks('ВымпелКом'))->toBe(0); +}); + +it('заглушка возвращает опознаваемый идентификатор', function () { + $result = (new FakeSmsProvider)->send(new SmsOutgoing( + phone: '79990000001', + body: 'проверка', + senderName: 'liderra.ru', + operator: 'МТС', + segments: 1, + )); + + expect($result->providerMessageId)->toStartWith('fake-') + ->and($result->costKopecks)->toBe(0); +}); +``` + +- [ ] **Шаг 2: Убедиться, что падает** + +Run: `cd app && composer test -- --filter=FakeSmsProviderTest` +Expected: FAIL — `Class "App\Services\Sms\Providers\FakeSmsProvider" not found`. + +- [ ] **Шаг 3: DTO и интерфейс** + +`app/app/Services/Sms/SmsOutgoing.php`: + +```php + + */ + public function servesOperators(): array; + + /** Цена одного сегмента в копейках для данного оператора. */ + public function priceKopecks(string $operator): int; + + /** @throws SmsSendException при отказе провайдера. */ + public function send(SmsOutgoing $message): SmsSendResult; +} +``` + +`app/app/Services/Sms/SmsSendException.php`: + +```php + $message->phone, + 'operator' => $message->operator, + 'segments' => $message->segments, + ]); + + return new SmsSendResult( + providerMessageId: 'fake-'.Str::uuid()->toString(), + segments: $message->segments, + costKopecks: 0, + acceptedAt: CarbonImmutable::now(), + ); + } +} +``` + +- [ ] **Шаг 4: Конфиг** + +В `app/config/services.php` добавить секцию (проверить перед вставкой, что ключа `sms` +там ещё нет — в этом файле уже был случай дублирующегося ключа, PHP молча берёт +последний): + +```php +'sms' => [ + // Песочница: провайдеры не вызываются, отправка только помечается в журнале. + // Пока имя отправителя не согласовано операторами — единственный рабочий режим. + 'sandbox' => (bool) env('SMS_SANDBOX', true), + + '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), + ], +], +``` + +- [ ] **Шаг 5: Убедиться, что зелено** + +Run: `cd app && composer test -- --filter=FakeSmsProviderTest` +Expected: PASS, 3 теста. + +- [ ] **Шаг 6: Коммит** + +```bash +git add app/app/Services/Sms app/config/services.php app/tests/Unit/Sms/FakeSmsProviderTest.php +git commit -m "feat(смс): разъём провайдера и заглушка для работы до модерации имени" +``` + +--- + +## Task 4: Счётчик сегментов и цены + +**Files:** +- Create: `app/app/Services/Sms/SmsSegmentCalculator.php` +- Test: `app/tests/Unit/Sms/SmsSegmentCalculatorTest.php` + +Правило операторов: до 70 символов — 1 сегмент; длиннее — режется по 67. +Тексты у нас русские, поэтому кириллический лимит, а не латинский. + +- [ ] **Шаг 1: Тест, который падает** + +```php +segments(str_repeat('а', 70)))->toBe(1); +}); + +it('71 символ — уже два сегмента', function () { + expect((new SmsSegmentCalculator)->segments(str_repeat('а', 71)))->toBe(2); +}); + +it('134 символа — два сегмента, 135 — три', function () { + $calc = new SmsSegmentCalculator; + + expect($calc->segments(str_repeat('а', 134)))->toBe(2) + ->and($calc->segments(str_repeat('а', 135)))->toBe(3); +}); + +it('пустой текст — один сегмент, а не ноль', function () { + expect((new SmsSegmentCalculator)->segments(''))->toBe(1); +}); + +it('считает кириллицу посимвольно, а не побайтово', function () { + // «привет» — 6 символов, но 12 байт. Побайтовый счёт дал бы неверный результат. + expect((new SmsSegmentCalculator)->segments(str_repeat('привет', 11)))->toBe(1); +}); +``` + +- [ ] **Шаг 2: Убедиться, что падает** + +Run: `cd app && composer test -- --filter=SmsSegmentCalculatorTest` +Expected: FAIL — класс не найден. + +- [ ] **Шаг 3: Реализация** + +```php +segments($body) * $segmentPriceKopecks; + } +} +``` + +- [ ] **Шаг 4: Убедиться, что зелено** + +Run: `cd app && composer test -- --filter=SmsSegmentCalculatorTest` +Expected: PASS, 5 тестов. + +- [ ] **Шаг 5: Коммит** + +```bash +git add app/app/Services/Sms/SmsSegmentCalculator.php app/tests/Unit/Sms/SmsSegmentCalculatorTest.php +git commit -m "feat(смс): счётчик сегментов и цены по кириллическим правилам" +``` + +--- + +## Task 5: Маршрутизация по оператору + +**Files:** +- Create: `app/app/Services/Sms/SmsRouter.php` +- Test: `app/tests/Unit/Sms/SmsRouterTest.php` + +- [ ] **Шаг 1: Тест, который падает** + +```php +k; + } + + public function servesOperators(): array + { + return $this->ops; + } + + public function priceKopecks(string $operator): int + { + return $this->price; + } + + public function send(SmsOutgoing $m): SmsSendResult + { + return new SmsSendResult('x', 1, $this->price, CarbonImmutable::now()); + } + }; +} + +it('билайновский номер уходит через Билайн, а не через дорогого универсала', function () { + $router = new SmsRouter([ + fakeProviderFor('mts', ['*'], 990), + fakeProviderFor('beeline', ['ВымпелКом'], 475), + ]); + + expect($router->pick('ВымпелКом')?->key())->toBe('beeline'); +}); + +it('чужой оператор уходит через универсала', function () { + $router = new SmsRouter([ + fakeProviderFor('mts', ['*'], 990), + fakeProviderFor('beeline', ['ВымпелКом'], 475), + ]); + + expect($router->pick('МегаФон')?->key())->toBe('mts'); +}); + +it('при неизвестном операторе берёт универсала', function () { + $router = new SmsRouter([fakeProviderFor('mts', ['*'], 990)]); + + expect($router->pick('')?->key())->toBe('mts'); +}); + +it('без подходящего провайдера возвращает null — номер будет пропущен, деньги целы', function () { + $router = new SmsRouter([fakeProviderFor('beeline', ['ВымпелКом'], 475)]); + + expect($router->pick('МегаФон'))->toBeNull(); +}); + +it('из двух подходящих выбирает дешёвого', function () { + $router = new SmsRouter([ + fakeProviderFor('dear', ['МТС'], 900), + fakeProviderFor('cheap', ['МТС'], 503), + ]); + + expect($router->pick('МТС')?->key())->toBe('cheap'); +}); +``` + +- [ ] **Шаг 2: Убедиться, что падает** + +Run: `cd app && composer test -- --filter=SmsRouterTest` +Expected: FAIL — класс не найден. + +- [ ] **Шаг 3: Реализация** + +```php + $providers */ + public function __construct(private readonly array $providers) {} + + public function pick(string $operator): ?SmsProvider + { + $exact = []; + $universal = []; + + foreach ($this->providers as $provider) { + $serves = $provider->servesOperators(); + + if ($operator !== '' && in_array($operator, $serves, true)) { + $exact[] = $provider; + } elseif (in_array('*', $serves, true)) { + $universal[] = $provider; + } + } + + $candidates = $exact !== [] ? $exact : $universal; + + if ($candidates === []) { + return null; + } + + usort( + $candidates, + fn (SmsProvider $a, SmsProvider $b) => $a->priceKopecks($operator) <=> $b->priceKopecks($operator), + ); + + return $candidates[0]; + } +} +``` + +- [ ] **Шаг 4: Убедиться, что зелено** + +Run: `cd app && composer test -- --filter=SmsRouterTest` +Expected: PASS, 5 тестов. + +- [ ] **Шаг 5: Коммит** + +```bash +git add app/app/Services/Sms/SmsRouter.php app/tests/Unit/Sms/SmsRouterTest.php +git commit -m "feat(смс): маршрутизация по оператору — дешёвый канал выбирается сам" +``` + +--- + +## Task 6: Отбор получателей и причины пропуска + +**Files:** +- Create: `app/app/Services/Sms/SmsRecipientSelector.php` +- Test: `app/tests/Feature/Sales/SmsRecipientSelectorTest.php` + +- [ ] **Шаг 1: Тест, который падает** + +```php +selector = new SmsRecipientSelector(new SmsRouter([new FakeSmsProvider])); +}); + +function makePhone(string $phone, array $overrides = []): SalesAdAudiencePhone +{ + return SalesAdAudiencePhone::create(array_merge([ + 'phone' => $phone, + 'expires_at' => now()->addDays(30), + 'state' => 'active', + 'operator' => 'МТС', + 'phone_type' => 'mobile', + ], $overrides)); +} + +it('годный номер попадает в отправку', function () { + makePhone('79990000001'); + + $plan = $this->selector->build(['79990000001']); + + expect($plan->sendable)->toHaveCount(1) + ->and($plan->skipped)->toBeEmpty(); +}); + +it('номер из стоп-листа не отправляется никогда', function () { + makePhone('79990000002'); + SalesSmsOptout::create(['phone' => '79990000002', 'reason' => 'manual', 'created_at' => now()]); + + $plan = $this->selector->build(['79990000002']); + + expect($plan->sendable)->toBeEmpty() + ->and($plan->skipped['79990000002'])->toBe(SalesSmsMessage::SKIP_OPTOUT); +}); + +it('городской номер не отправляется — деньги сгорели бы впустую', function () { + makePhone('79990000003', ['phone_type' => 'landline']); + + $plan = $this->selector->build(['79990000003']); + + expect($plan->skipped['79990000003'])->toBe(SalesSmsMessage::SKIP_NOT_MOBILE); +}); + +it('погашенный номер не отправляется', function () { + makePhone('79990000004', ['removed_at' => now()]); + + $plan = $this->selector->build(['79990000004']); + + expect($plan->skipped['79990000004'])->toBe(SalesSmsMessage::SKIP_INACTIVE); +}); + +it('дубль внутри одного запуска считается один раз', function () { + makePhone('79990000005'); + + $plan = $this->selector->build(['79990000005', '79990000005']); + + expect($plan->sendable)->toHaveCount(1); +}); + +it('без подходящего провайдера номер помечается без маршрута', function () { + makePhone('79990000006'); + $selector = new SmsRecipientSelector(new SmsRouter([])); + + $plan = $selector->build(['79990000006']); + + expect($plan->skipped['79990000006'])->toBe(SalesSmsMessage::SKIP_NO_ROUTE); +}); +``` + +- [ ] **Шаг 2: Убедиться, что падает** + +Run: `cd app && composer test -- --filter=SmsRecipientSelectorTest` +Expected: FAIL — класс не найден. + +- [ ] **Шаг 3: Реализация** + +`app/app/Services/Sms/SmsPlan.php`: + +```php + $sendable + * @param array $skipped номер => причина + */ + public function __construct( + public array $sendable, + public array $skipped, + ) {} +} +``` + +`app/app/Services/Sms/SmsRecipientSelector.php`: + +```php + $phones */ + public function build(array $phones, ?int $tenantId = null): SmsPlan + { + $unique = array_values(array_unique($phones)); + + $optouts = SalesSmsOptout::query() + ->when($tenantId === null, fn ($q) => $q->whereNull('tenant_id')) + ->when($tenantId !== null, fn ($q) => $q->where('tenant_id', $tenantId)) + ->whereIn('phone', $unique) + ->pluck('phone') + ->all(); + + /** @var array $rows */ + $rows = SalesAdAudiencePhone::whereIn('phone', $unique)->get()->keyBy('phone')->all(); + + $sendable = []; + $skipped = []; + + foreach ($unique as $phone) { + if (in_array($phone, $optouts, true)) { + $skipped[$phone] = SalesSmsMessage::SKIP_OPTOUT; + + continue; + } + + $row = $rows[$phone] ?? null; + + if ($row === null || $row->removed_at !== null || $row->state !== 'active') { + $skipped[$phone] = SalesSmsMessage::SKIP_INACTIVE; + + continue; + } + + // Страховка: по построению в прогрев попадают только мобильные, + // но номер мог прийти мимо обычного пути. + if ($row->phone_type !== null && $row->phone_type !== 'mobile') { + $skipped[$phone] = SalesSmsMessage::SKIP_NOT_MOBILE; + + continue; + } + + if ($this->router->pick((string) $row->operator) === null) { + $skipped[$phone] = SalesSmsMessage::SKIP_NO_ROUTE; + + continue; + } + + $sendable[] = $row; + } + + return new SmsPlan($sendable, $skipped); + } +} +``` + +- [ ] **Шаг 4: Убедиться, что зелено** + +Run: `cd app && composer test -- --filter=SmsRecipientSelectorTest` +Expected: PASS, 6 тестов. + +- [ ] **Шаг 5: Проверить защиту вырезанием** + +Временно закомментируй в `SmsRecipientSelector::build()` блок проверки стоп-листа +(`if (in_array($phone, $optouts, true))`). Запусти тесты. + +Expected: тест «номер из стоп-листа не отправляется никогда» **покраснел**. +Если он остался зелёным — тест не проверяет то, что должен, и его надо чинить. + +Верни блок на место, убедись, что снова зелено. + +- [ ] **Шаг 6: Коммит** + +```bash +git add app/app/Services/Sms/SmsPlan.php app/app/Services/Sms/SmsRecipientSelector.php app/tests/Feature/Sales/SmsRecipientSelectorTest.php +git commit -m "feat(смс): отбор получателей — стоп-лист, тип номера, маршрут" +``` + +--- + +## Task 7: Списание + +**Files:** +- Create: `app/app/Services/Sms/SmsChargeService.php` +- Create: `app/database/migrations/2026_07_23_100100_extend_balance_transactions_type_for_sms.php` +- Modify: `app/app/Models/BalanceTransaction.php` +- Test: `app/tests/Feature/Sales/SmsChargeServiceTest.php` + +Копируем приём из `app/app/Services/Autopodbor/AutopodborChargeService.php` — не изобретаем. + +- [ ] **Шаг 1: Тест, который падает** + +```php + 'Своя', 'body' => 'текст', 'created_by' => 1, 'tenant_id' => null, + ]); + + (new SmsChargeService)->charge($campaign, '150.00'); + + expect($campaign->refresh()->balance_transaction_id)->toBeNull(); +}); + +it('списывает с баланса клиента и пишет операцию', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + $campaign = SalesSmsCampaign::create([ + 'title' => 'Клиентская', 'body' => 'текст', 'created_by' => 1, 'tenant_id' => $tenant->id, + ]); + + (new SmsChargeService)->charge($campaign, '150.00'); + + expect((string) $tenant->refresh()->balance_rub)->toBe('850.00') + ->and($campaign->refresh()->balance_transaction_id)->not->toBeNull(); + + $tx = BalanceTransaction::find($campaign->balance_transaction_id); + expect($tx->type)->toBe(BalanceTransaction::TYPE_SMS_CHARGE) + ->and($tx->amount_rub)->toBe('-150.00'); +}); + +it('повторный запуск не списывает второй раз', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + $campaign = SalesSmsCampaign::create([ + 'title' => 'Повтор', 'body' => 'текст', 'created_by' => 1, 'tenant_id' => $tenant->id, + ]); + + $service = new SmsChargeService; + $service->charge($campaign, '150.00'); + $service->charge($campaign->refresh(), '150.00'); + + expect((string) $tenant->refresh()->balance_rub)->toBe('850.00'); +}); + +it('при нехватке денег не списывает ничего', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '10.00']); + $campaign = SalesSmsCampaign::create([ + 'title' => 'Бедная', 'body' => 'текст', 'created_by' => 1, 'tenant_id' => $tenant->id, + ]); + + expect(fn () => (new SmsChargeService)->charge($campaign, '150.00')) + ->toThrow(InsufficientBalanceException::class); + + expect((string) $tenant->refresh()->balance_rub)->toBe('10.00') + ->and($campaign->refresh()->balance_transaction_id)->toBeNull(); +}); +``` + +- [ ] **Шаг 2: Убедиться, что падает** + +Run: `cd app && composer test -- --filter=SmsChargeServiceTest` +Expected: FAIL — класс не найден. + +- [ ] **Шаг 3: Миграция типа операции** + +```php +tenant_id === null) { + return; + } + + DB::transaction(function () use ($campaign, $priceRub): void { + /** @var SalesSmsCampaign $locked */ + $locked = SalesSmsCampaign::whereKey($campaign->id)->lockForUpdate()->firstOrFail(); + + if ($locked->balance_transaction_id !== null) { + return; + } + + if (bccomp($priceRub, '0', 2) === 0) { + return; + } + + /** @var Tenant $tenant */ + $tenant = Tenant::whereKey($locked->tenant_id)->lockForUpdate()->firstOrFail(); + + if (bccomp((string) $tenant->balance_rub, $priceRub, 2) < 0) { + throw new InsufficientBalanceException( + priceKopecks: (int) bcmul($priceRub, '100', 0), + balanceRub: (string) $tenant->balance_rub, + ); + } + + $newBalance = bcsub((string) $tenant->balance_rub, $priceRub, 2); + + DB::table('tenants')->where('id', $tenant->id)->update(['balance_rub' => $newBalance]); + + $tx = BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_SMS_CHARGE, + 'amount_rub' => '-'.$priceRub, + 'amount_leads' => null, + 'balance_rub_after' => $newBalance, + 'balance_leads_after' => null, + 'related_type' => SalesSmsCampaign::class, + 'related_id' => $locked->id, + 'created_at' => now(), + ]); + + $locked->balance_transaction_id = $tx->id; + $locked->save(); + }); + } +} +``` + +- [ ] **Шаг 5: Убедиться, что зелено** + +Run: `cd app && composer test -- --filter=SmsChargeServiceTest` +Expected: PASS, 4 теста. + +- [ ] **Шаг 6: Проверить защиту вырезанием** + +Временно убери guard `if ($locked->balance_transaction_id !== null) { return; }`. + +Expected: тест «повторный запуск не списывает второй раз» **покраснел** (баланс стал 700.00). + +Верни на место. + +- [ ] **Шаг 7: Коммит** + +```bash +git add app/app/Services/Sms/SmsChargeService.php app/database/migrations/2026_07_23_100100_extend_balance_transactions_type_for_sms.php app/app/Models/BalanceTransaction.php app/tests/Feature/Sales/SmsChargeServiceTest.php db/CHANGELOG_schema.md +git commit -m "feat(смс): списание за рассылку с защитой от двойного списания" +``` + +--- + +## Task 8: Джоб отправки + +**Files:** +- Create: `app/app/Jobs/SendSmsCampaignJob.php` +- Test: `app/tests/Feature/Sales/SendSmsCampaignJobTest.php` + +- [ ] **Шаг 1: Тест, который падает** + +```php + 'liderra.ru', 'provider_key' => 'fake', 'status' => 'active', + ]); + + foreach ($phones as $p) { + SalesAdAudiencePhone::create([ + 'phone' => $p, 'expires_at' => now()->addDays(30), + 'state' => 'active', 'operator' => 'МТС', 'phone_type' => 'mobile', + ]); + } + + return SalesSmsCampaign::create([ + 'title' => 'Прогрев', 'body' => 'Здравствуйте!', 'created_by' => 1, + 'sender_id' => $sender->id, 'status' => SalesSmsCampaign::STATUS_QUEUED, + ]); +} + +it('отправляет каждому годному номеру и закрывает кампанию', function () { + $campaign = campaignWithPhones(['79990000001', '79990000002']); + + (new SendSmsCampaignJob($campaign->id, ['79990000001', '79990000002']))->handle(); + + $campaign->refresh(); + expect($campaign->status)->toBe(SalesSmsCampaign::STATUS_DONE) + ->and($campaign->sent_count)->toBe(2) + ->and($campaign->messages)->toHaveCount(2); +}); + +it('пропущенные номера попадают в журнал с причиной, но не в отправку', function () { + $campaign = campaignWithPhones(['79990000003', '79990000004']); + SalesSmsOptout::create(['phone' => '79990000004', 'reason' => 'manual', 'created_at' => now()]); + + (new SendSmsCampaignJob($campaign->id, ['79990000003', '79990000004']))->handle(); + + $campaign->refresh(); + expect($campaign->sent_count)->toBe(1) + ->and($campaign->skipped_count)->toBe(1); + + $skipped = SalesSmsMessage::where('campaign_id', $campaign->id) + ->where('phone', '79990000004')->first(); + + expect($skipped->status)->toBe(SalesSmsMessage::SKIP_OPTOUT) + ->and($skipped->cost_kopecks)->toBe(0); +}); + +it('не стартует без согласованного имени отправителя', function () { + $campaign = campaignWithPhones(['79990000005']); + $campaign->sender->update(['status' => 'pending']); + + (new SendSmsCampaignJob($campaign->id, ['79990000005']))->handle(); + + $campaign->refresh(); + expect($campaign->status)->toBe(SalesSmsCampaign::STATUS_FAILED) + ->and($campaign->last_error)->toContain('имя отправителя') + ->and($campaign->messages)->toHaveCount(0); +}); + +it('повторный запуск не создаёт вторых сообщений', function () { + $campaign = campaignWithPhones(['79990000006']); + + (new SendSmsCampaignJob($campaign->id, ['79990000006']))->handle(); + (new SendSmsCampaignJob($campaign->id, ['79990000006']))->handle(); + + expect(SalesSmsMessage::where('campaign_id', $campaign->id)->count())->toBe(1); +}); + +it('при нехватке денег у клиента не уходит ни одной СМС', function () { + $tenant = App\Models\Tenant::factory()->create(['balance_rub' => '1.00']); + $campaign = campaignWithPhones(['79990000007']); + $campaign->update(['tenant_id' => $tenant->id, 'estimated_cost_kopecks' => 50000]); + + (new SendSmsCampaignJob($campaign->id, ['79990000007']))->handle(); + + $campaign->refresh(); + expect($campaign->status)->toBe(SalesSmsCampaign::STATUS_FAILED) + ->and($campaign->sent_count)->toBe(0) + ->and($campaign->last_error)->toContain('Не хватило денег'); + + $row = SalesSmsMessage::where('campaign_id', $campaign->id)->first(); + expect($row->status)->toBe(SalesSmsMessage::SKIP_NO_FUNDS); + + // Деньги не тронуты: списания не было, потому что его не хватало. + expect((string) $tenant->refresh()->balance_rub)->toBe('1.00'); +}); +``` + +- [ ] **Шаг 2: Убедиться, что падает** + +Run: `cd app && composer test -- --filter=SendSmsCampaignJobTest` +Expected: FAIL — класс не найден. + +- [ ] **Шаг 3: Реализация** + +```php + $phones */ + public function __construct( + private readonly int $campaignId, + private readonly array $phones, + ) {} + + public function handle( + ?SmsRecipientSelector $selector = null, + ?SmsRouter $router = null, + ?SmsSegmentCalculator $calculator = null, + ?SmsChargeService $charge = null, + ): void { + $router ??= app(SmsRouter::class); + $selector ??= new SmsRecipientSelector($router); + $calculator ??= new SmsSegmentCalculator; + + $campaign = SalesSmsCampaign::find($this->campaignId); + + if ($campaign === null) { + Log::warning('sms.campaign_not_found', ['campaign_id' => $this->campaignId]); + + return; + } + + $sender = $campaign->sender; + + if ($sender === null || ! $sender->isActive()) { + $campaign->update([ + 'status' => SalesSmsCampaign::STATUS_FAILED, + 'last_error' => 'Не согласовано имя отправителя — рассылка не запускалась.', + 'finished_at' => now(), + ]); + + Log::warning('sms.sender_not_active', ['campaign_id' => $campaign->id]); + + return; + } + + $campaign->update(['status' => SalesSmsCampaign::STATUS_SENDING, 'started_at' => now()]); + + $already = SalesSmsMessage::where('campaign_id', $campaign->id)->pluck('phone')->all(); + $plan = $selector->build($this->phones, $campaign->tenant_id); + + // Списываем ДО отправки, по расчётной цене. Не хватило — не уходит ничего. + $charge ??= new SmsChargeService; + $estimatedRub = bcdiv((string) $campaign->estimated_cost_kopecks, '100', 2); + + try { + $charge->charge($campaign, $estimatedRub); + } catch (InsufficientBalanceException $e) { + foreach ($plan->sendable as $row) { + if (in_array($row->phone, $already, true)) { + continue; + } + + SalesSmsMessage::create([ + 'campaign_id' => $campaign->id, + 'phone' => $row->phone, + 'status' => SalesSmsMessage::SKIP_NO_FUNDS, + 'segments' => 0, + 'cost_kopecks' => 0, + ]); + } + + $campaign->update([ + 'status' => SalesSmsCampaign::STATUS_FAILED, + 'last_error' => 'Не хватило денег на балансе — не отправлено ни одной СМС.', + 'skipped_count' => count($plan->sendable), + 'finished_at' => now(), + ]); + + Log::warning('sms.insufficient_balance', ['campaign_id' => $campaign->id]); + + return; + } + + $sent = 0; + $failed = 0; + $skippedCount = 0; + $cost = 0; + + foreach ($plan->skipped as $phone => $reason) { + if (in_array($phone, $already, true)) { + continue; + } + + SalesSmsMessage::create([ + 'campaign_id' => $campaign->id, + 'phone' => $phone, + 'status' => $reason, + 'segments' => 0, + 'cost_kopecks' => 0, + ]); + + $skippedCount++; + } + + foreach ($plan->sendable as $row) { + if (in_array($row->phone, $already, true)) { + continue; + } + + $operator = (string) $row->operator; + $provider = $router->pick($operator); + + if ($provider === null) { + SalesSmsMessage::create([ + 'campaign_id' => $campaign->id, + 'phone' => $row->phone, + 'status' => SalesSmsMessage::SKIP_NO_ROUTE, + 'segments' => 0, + 'cost_kopecks' => 0, + ]); + $skippedCount++; + + continue; + } + + $segments = $calculator->segments($campaign->body); + + try { + $result = $provider->send(new SmsOutgoing( + phone: $row->phone, + body: $campaign->body, + senderName: $sender->name, + operator: $operator, + segments: $segments, + )); + + SalesSmsMessage::create([ + 'campaign_id' => $campaign->id, + 'phone' => $row->phone, + 'phone_id' => $row->id, + 'operator' => $operator, + 'phone_type' => $row->phone_type, + 'provider_key' => $provider->key(), + 'status' => $provider->key() === 'fake' + ? SalesSmsMessage::STATUS_FAKE_SENT + : SalesSmsMessage::STATUS_SENT, + 'segments' => $result->segments, + 'cost_kopecks' => $result->costKopecks, + 'provider_message_id' => $result->providerMessageId, + 'sent_at' => $result->acceptedAt, + ]); + + $sent++; + $cost += $result->costKopecks; + } catch (SmsSendException $e) { + SalesSmsMessage::create([ + 'campaign_id' => $campaign->id, + 'phone' => $row->phone, + 'phone_id' => $row->id, + 'operator' => $operator, + 'provider_key' => $provider->key(), + 'status' => SalesSmsMessage::STATUS_FAILED, + 'segments' => 0, + 'cost_kopecks' => 0, + 'error' => $e->getMessage(), + ]); + + $failed++; + + Log::warning('sms.send_failed', [ + 'campaign_id' => $campaign->id, + 'provider' => $provider->key(), + 'terminal' => $e->terminal, + ]); + } + } + + $campaign->update([ + 'status' => SalesSmsCampaign::STATUS_DONE, + 'sent_count' => $campaign->sent_count + $sent, + 'failed_count' => $campaign->failed_count + $failed, + 'skipped_count' => $campaign->skipped_count + $skippedCount, + 'actual_cost_kopecks' => $campaign->actual_cost_kopecks + $cost, + 'finished_at' => now(), + ]); + + Log::info('sms.campaign_done', [ + 'campaign_id' => $campaign->id, + 'sent' => $sent, 'failed' => $failed, 'skipped' => $skippedCount, + ]); + } + + public function failed(Throwable $e): void + { + Log::error('sms.campaign_failed_permanently', [ + 'campaign_id' => $this->campaignId, + 'error' => $e->getMessage(), + ]); + + SalesSmsCampaign::whereKey($this->campaignId)->update([ + 'status' => SalesSmsCampaign::STATUS_FAILED, + 'last_error' => $e->getMessage(), + 'finished_at' => now(), + ]); + } +} +``` + +- [ ] **Шаг 4: Зарегистрировать роутер в контейнере** + +В `app/app/Providers/AppServiceProvider.php` в методе `register()`: + +```php +$this->app->singleton(\App\Services\Sms\SmsRouter::class, function () { + $providers = []; + + // Песочница включена — работает только заглушка, живые провайдеры не вызываются. + if (config('services.sms.sandbox')) { + $providers[] = new \App\Services\Sms\Providers\FakeSmsProvider; + } + + return new \App\Services\Sms\SmsRouter($providers); +}); +``` + +- [ ] **Шаг 5: Убедиться, что зелено** + +Run: `cd app && composer test -- --filter=SendSmsCampaignJobTest` +Expected: PASS, 4 теста. + +- [ ] **Шаг 6: Коммит** + +```bash +git add app/app/Jobs/SendSmsCampaignJob.php app/app/Providers/AppServiceProvider.php app/tests/Feature/Sales/SendSmsCampaignJobTest.php +git commit -m "feat(смс): джоб отправки рассылки с журналом по каждому номеру" +``` + +--- + +## Task 9: API начальника + +**Files:** +- Create: `app/app/Http/Controllers/Api/Sales/SalesSmsController.php` +- Modify: `app/routes/web.php` +- Test: `app/tests/Feature/Sales/SalesSmsScreenTest.php` + +- [ ] **Шаг 1: Тест, который падает** + +```php +create(['role' => 'manager']); + + $this->actingAs($manager, 'sales') + ->getJson('/api/sales/sms/campaigns') + ->assertStatus(403); +}); + +it('начальник видит список рассылок', function () { + $head = SalesUser::factory()->create(['role' => 'head']); + + $this->actingAs($head, 'sales') + ->getJson('/api/sales/sms/campaigns') + ->assertOk() + ->assertJsonStructure(['campaigns', 'sandbox']); +}); + +it('предпросмотр показывает цену и причины пропуска до отправки', function () { + $head = SalesUser::factory()->create(['role' => 'head']); + + $this->actingAs($head, 'sales') + ->postJson('/api/sales/sms/preview', [ + 'body' => str_repeat('а', 80), + 'phones' => ['79990000001'], + ]) + ->assertOk() + ->assertJsonStructure(['segments', 'sendable_count', 'skipped', 'estimated_cost_kopecks']) + ->assertJsonPath('segments', 2); +}); + +it('не создаёт рассылку с пустым текстом', function () { + $head = SalesUser::factory()->create(['role' => 'head']); + + $this->actingAs($head, 'sales') + ->postJson('/api/sales/sms/campaigns', ['title' => 'Пустая', 'body' => '', 'phones' => ['79990000001']]) + ->assertStatus(422); +}); +``` + +- [ ] **Шаг 2: Убедиться, что падает** + +Run: `cd app && composer test -- --filter=SalesSmsScreenTest` +Expected: FAIL — маршрут не найден (404). + +- [ ] **Шаг 3: Контроллер** + +```php +denyIfNotHead($request)) !== null) { + return $resp; + } + + return response()->json([ + 'campaigns' => SalesSmsCampaign::orderByDesc('id')->limit(50)->get(), + 'sandbox' => (bool) config('services.sms.sandbox'), + 'senders' => SalesSmsSender::orderBy('name')->get(), + ]); + } + + /** Что уйдёт и почём — ДО отправки. */ + public function preview(Request $request): JsonResponse + { + if (($resp = $this->denyIfNotHead($request)) !== null) { + return $resp; + } + + $data = $request->validate([ + 'body' => 'required|string|max:1000', + 'phones' => 'required|array|min:1', + 'phones.*' => 'string|size:11', + ]); + + $plan = (new SmsRecipientSelector($this->router))->build($data['phones']); + $segments = $this->calculator->segments($data['body']); + + $cost = 0; + + foreach ($plan->sendable as $row) { + $provider = $this->router->pick((string) $row->operator); + $cost += $segments * ($provider?->priceKopecks((string) $row->operator) ?? 0); + } + + return response()->json([ + 'segments' => $segments, + 'sendable_count' => count($plan->sendable), + 'skipped' => $plan->skipped, + 'estimated_cost_kopecks' => $cost, + ]); + } + + public function store(Request $request): JsonResponse + { + if (($resp = $this->denyIfNotHead($request)) !== null) { + return $resp; + } + + $data = $request->validate([ + 'title' => 'required|string|max:200', + 'body' => 'required|string|min:1|max:1000', + 'phones' => 'required|array|min:1', + 'phones.*' => 'string|size:11', + 'sender_id' => 'nullable|integer', + ]); + + /** @var SalesUser $user */ + $user = $request->user('sales'); + + // Расчётная цена считается здесь и хранится на кампании: именно её + // спишет джоб. Без неё списание получило бы ноль. + $plan = (new SmsRecipientSelector($this->router))->build($data['phones']); + $segments = $this->calculator->segments($data['body']); + $estimated = 0; + + foreach ($plan->sendable as $row) { + $provider = $this->router->pick((string) $row->operator); + $estimated += $segments * ($provider?->priceKopecks((string) $row->operator) ?? 0); + } + + $campaign = SalesSmsCampaign::create([ + 'title' => $data['title'], + 'body' => $data['body'], + 'sender_id' => $data['sender_id'] ?? null, + 'created_by' => $user->id, + 'status' => SalesSmsCampaign::STATUS_QUEUED, + 'planned_count' => count($data['phones']), + 'estimated_cost_kopecks' => $estimated, + ]); + + SendSmsCampaignJob::dispatch($campaign->id, $data['phones']); + + return response()->json(['campaign' => $campaign], 201); + } + + public function show(Request $request, int $id): JsonResponse + { + if (($resp = $this->denyIfNotHead($request)) !== null) { + return $resp; + } + + $campaign = SalesSmsCampaign::with('messages')->findOrFail($id); + + return response()->json(['campaign' => $campaign]); + } + + private function denyIfNotHead(Request $request): ?JsonResponse + { + /** @var SalesUser $user */ + $user = $request->user('sales'); + + if (! $user->isHead()) { + return response()->json(['message' => 'Доступно только начальнику отдела.'], 403); + } + + return null; + } +} +``` + +- [ ] **Шаг 4: Маршруты** + +В `app/routes/web.php` в ту же группу с middleware `admin-db`, где лежат маршруты +`/api/sales/ad-audience` (около строк 305–314), добавить: + +```php +Route::get('/api/sales/sms/campaigns', [SalesSmsController::class, 'index']); +Route::post('/api/sales/sms/campaigns', [SalesSmsController::class, 'store']); +Route::get('/api/sales/sms/campaigns/{id}', [SalesSmsController::class, 'show']); +Route::post('/api/sales/sms/preview', [SalesSmsController::class, 'preview']); +``` + +Не забыть `use App\Http\Controllers\Api\Sales\SalesSmsController;` вверху файла. + +- [ ] **Шаг 5: Убедиться, что зелено** + +Run: `cd app && composer test -- --filter=SalesSmsScreenTest` +Expected: PASS, 4 теста. + +- [ ] **Шаг 6: Проверить защиту вырезанием** + +Временно убери вызов `denyIfNotHead` из метода `index`. + +Expected: тест «менеджеру раздел закрыт» **покраснел** (200 вместо 403). + +Верни на место. + +- [ ] **Шаг 7: Коммит** + +```bash +git add app/app/Http/Controllers/Api/Sales/SalesSmsController.php app/routes/web.php app/tests/Feature/Sales/SalesSmsScreenTest.php +git commit -m "feat(смс): API раздела — список, предпросмотр цены, запуск" +``` + +--- + +## Task 10: Экран + +**Files:** +- Create: `app/resources/js/views/sales/SalesSmsView.vue` +- Modify: `app/resources/js/router/index.ts` +- Test: `app/tests/Frontend/SalesSmsView.spec.ts` + +- [ ] **Шаг 1: Тест, который падает** + +```typescript +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const view = readFileSync( + resolve(__dirname, '../../resources/js/views/sales/SalesSmsView.vue'), + 'utf-8', +); + +describe('Экран «Прогрев СМС»', () => { + it('показывает цену до отправки', () => { + expect(view).toContain('estimated_cost_kopecks'); + }); + + it('помечает режим песочницы явно — «отправлено» нельзя спутать с настоящей отправкой', () => { + expect(view).toMatch(/песочниц/i); + }); + + it('блокирует кнопку без согласованного имени отправителя', () => { + expect(view).toMatch(/:disabled/); + expect(view).toMatch(/имя отправителя/i); + }); + + it('показывает счётчик сегментов', () => { + expect(view).toContain('segments'); + }); + + it('показывает причины пропуска номеров', () => { + expect(view).toContain('skipped'); + }); +}); +``` + +- [ ] **Шаг 2: Убедиться, что падает** + +Run: `cd app && npm run test:vue -- SalesSmsView` +Expected: FAIL — файла нет (ENOENT). + +- [ ] **Шаг 3: Экран** + +Создать `app/resources/js/views/sales/SalesSmsView.vue` по образцу +`app/resources/js/views/sales/SalesAdAudienceView.vue` (тот же каркас: ``, +таблица, действия). Обязательный состав: + +- баннер `v-alert` с текстом про **режим песочницы**, видимый когда `sandbox === true`; +- `v-textarea` для текста с подписью-счётчиком: символов, `segments`, рублей + (`estimated_cost_kopecks / 100`); +- строка-итог: «из N отобранных уйдёт M», под ней расшифровка `skipped` по причинам + человеческими словами: `skipped_optout` → «в стоп-листе», `skipped_not_mobile` → + «городской номер», `skipped_inactive` → «номер не активен», `skipped_no_route` → + «нет канала для оператора»; +- кнопка «Отправить» с `:disabled`, когда нет активного имени отправителя, и подсказкой + «Не согласовано имя отправителя»; +- `v-dialog` подтверждения с ценой прописью до запуска; +- таблица журнала по номерам: номер, оператор, канал, статус, цена. + +Предпросмотр дёргать `POST /api/sales/sms/preview` при изменении текста или набора +номеров (с задержкой ~400 мс, чтобы не стучать на каждую букву). + +- [ ] **Шаг 4: Маршрут экрана** + +В `app/resources/js/router/index.ts` рядом с маршрутом рекламной аудитории добавить: + +```typescript +{ + path: '/sales/sms', + name: 'sales-sms', + component: () => import('@/views/sales/SalesSmsView.vue'), + meta: { layout: 'app', requiresSalesHead: true }, +}, +``` + +- [ ] **Шаг 5: Убедиться, что зелено** + +Run: `cd app && npm run test:vue -- SalesSmsView` +Expected: PASS, 5 тестов. + +⚠️ Vitest **не проверяет типы**. Отдельно прогнать `npm run type-check`. + +- [ ] **Шаг 6: Коммит** + +```bash +git add app/resources/js/views/sales/SalesSmsView.vue app/resources/js/router/index.ts app/tests/Frontend/SalesSmsView.spec.ts +git commit -m "feat(смс): экран рассылки с ценой до отправки и пометкой песочницы" +``` + +--- + +## Task 10.5: Выбор получателей галочками, а не руками + +**Почему появилась.** Task 10 сдал экран, где номера вводятся вручную в текстовое поле. +Это расходится со спекой §7 («отбор получателей — те же фильтры, что в прогреве, с +галочками») и делает раздел непригодным: у начальника 99 фирм и 177 номеров, руками +их никто переписывать не станет. Причина не в исполнителе — ручки «дай номера отмеченных +фирм» в API просто нет, а трогать `api/sales.ts` ему было запрещено рамками задачи. + +**Files:** +- Modify: `app/app/Http/Controllers/Api/Sales/SalesSmsController.php` (+метод `recipients`) +- Modify: `app/routes/web.php` (+1 маршрут) +- Modify: `app/resources/js/api/sales.ts` (+обёртки вызовов СМС) +- Modify: `app/resources/js/views/sales/SalesSmsView.vue` (поле ввода → список с галочками) +- Test: `app/tests/Feature/Sales/SalesSmsScreenTest.php` (+2), `app/tests/Frontend/SalesSmsView.spec.ts` (+2) + +- [ ] **Шаг 1: Тест на новую ручку** + +Дописать в `app/tests/Feature/Sales/SalesSmsScreenTest.php`: + +```php +it('отдаёт номера отмеченных фирм', function () { + $firm = SalesAdAudienceFirm::create(['firm_name' => 'ООО Ромашка', 'firm_inn' => '2452108518']); + + SalesAdAudiencePhone::create([ + 'phone' => '79990000010', 'firm_id' => $firm->id, 'firm_name' => 'ООО Ромашка', + 'expires_at' => now()->addDays(30), 'state' => 'active', + 'operator' => 'МТС', 'phone_type' => 'mobile', + ]); + + $this->actingAs(sms_head(), 'sales') + ->postJson('/api/sales/sms/recipients', ['firm_ids' => [$firm->id]]) + ->assertOk() + ->assertJsonPath('phones.0.phone', '79990000010') + ->assertJsonPath('phones.0.firm_name', 'ООО Ромашка'); +}); + +it('не отдаёт погашенные номера', function () { + $firm = SalesAdAudienceFirm::create(['firm_name' => 'ООО Ушедшая', 'firm_inn' => '2452108519']); + + SalesAdAudiencePhone::create([ + 'phone' => '79990000011', 'firm_id' => $firm->id, + 'expires_at' => now()->addDays(30), 'state' => 'active', + 'removed_at' => now(), 'operator' => 'МТС', 'phone_type' => 'mobile', + ]); + + $this->actingAs(sms_head(), 'sales') + ->postJson('/api/sales/sms/recipients', ['firm_ids' => [$firm->id]]) + ->assertOk() + ->assertJsonPath('phones', []); +}); +``` + +- [ ] **Шаг 2: Убедиться, что падает** + +Run: `cd app && composer test -- --filter=SalesSmsScreenTest` +Expected: FAIL — маршрут `/sms/recipients` не зарегистрирован (405). + +- [ ] **Шаг 3: Метод контроллера** + +В `SalesSmsController` добавить: + +```php +/** Номера отмеченных фирм — чтобы начальник отмечал галочками, а не вбивал руками. */ +public function recipients(Request $request): JsonResponse +{ + if (($resp = $this->denyIfNotHead($request)) !== null) { + return $resp; + } + + $data = $request->validate([ + 'firm_ids' => 'required|array|min:1', + 'firm_ids.*' => 'integer', + ]); + + $phones = SalesAdAudiencePhone::query() + ->whereIn('firm_id', $data['firm_ids']) + ->whereNull('removed_at') + ->where('state', 'active') + ->orderBy('firm_name') + ->get(['phone', 'firm_name', 'city', 'operator', 'phone_type']); + + return response()->json(['phones' => $phones]); +} +``` + +Добавить `use App\Models\SalesAdAudiencePhone;` вверху файла. + +- [ ] **Шаг 4: Маршрут** + +В `app/routes/web.php` рядом со строками 317–320 (относительный путь, приставку +`api/sales` группа добавляет сама): + +```php +Route::post('/sms/recipients', [SalesSmsController::class, 'recipients']); +``` + +- [ ] **Шаг 5: Зелень** + +Run: `cd app && composer test -- --filter=SalesSmsScreenTest` +Expected: PASS, 6 тестов. Прогнать дважды. + +- [ ] **Шаг 6: Обёртки в общей прослойке** + +В `app/resources/js/api/sales.ts` добавить функции по образцу соседних: +`fetchSmsCampaigns()`, `fetchSmsCampaign(id)`, `previewSms(body, phones)`, +`createSmsCampaign(payload)`, `fetchSmsRecipients(firmIds)`. + +- [ ] **Шаг 7: Экран на галочки** + +В `SalesSmsView.vue` заменить текстовое поле ввода номеров на таблицу фирм с +`v-checkbox`, как в `SalesAdAudienceView.vue`. При смене набора отмеченных — +запрос `fetchSmsRecipients`, затем предпросмотр. Прямые вызовы `axios` из компонента +убрать, перевести на обёртки из шага 6. + +- [ ] **Шаг 8: Тест экрана** + +Дописать в `app/tests/Frontend/SalesSmsView.spec.ts`: + +```typescript +it('получателей отмечают галочками, а не вводят руками', () => { + expect(view).toContain('v-checkbox'); + expect(view).toContain('fetchSmsRecipients'); +}); + +it('ходит в сервер через общую прослойку, а не напрямую', () => { + expect(view).not.toContain('axios.post'); + expect(view).not.toContain('axios.get'); +}); +``` + +- [ ] **Шаг 9: Прогоны** + +Run: `cd app && composer test -- --filter=Sms` — зелено. +Run: `cd app && npm run test:vue -- SalesSmsView` — зелено, 7 тестов. +Run: `cd app && composer stan` — 0 ошибок (счётчик `actingAs` в списке игнора вырастет +с 4 до 6, поправить число). + +--- + +## Task 11: Провести оператора из Поиска клиентов + +**Files:** +- Modify: `моя/sales-finder/salesfinder/adaudience.py` +- Modify: `app/app/Services/Sales/AdAudienceIntake.php:69-88` +- Test: `моя/sales-finder/tests/test_adaudience.py`, `app/tests/Feature/Sales/AdAudienceIntakeOperatorTest.php` + +Оператор и тип уже определены и оплачены (`phonecheck.py` → `provider`, `kind`), +но `pick_ad_phones()` возвращает голые строки и теряет их. + +- [ ] **Шаг 1: Тест на стороне поисковика** + +В `моя/sales-finder/tests/test_adaudience.py`: + +```python +def test_отдаёт_оператора_вместе_с_номером(): + firm = {"contact_phones_checked": [ + {"phone": "79990000001", "kind": "mobile", "alive": "alive", "provider": "ВымпелКом"}, + ]} + + assert pick_ad_phones(firm) == [ + {"phone": "79990000001", "operator": "ВымпелКом", "phone_type": "mobile"}, + ] + + +def test_городской_по_прежнему_не_проходит(): + firm = {"contact_phones_checked": [ + {"phone": "74990000001", "kind": "landline", "alive": "alive", "provider": ""}, + ]} + + assert pick_ad_phones(firm) == [] +``` + +- [ ] **Шаг 2: Убедиться, что падает** + +Run: `cd моя/sales-finder && python -m pytest tests/test_adaudience.py -v` +Expected: FAIL — возвращается список строк, а не словарей. + +- [ ] **Шаг 3: Правка поисковика** + +```python +def pick_ad_phones(firm: dict[str, Any]) -> list[dict[str, str]]: + checked = firm.get("contact_phones_checked") or [] + picked: list[dict[str, str]] = [] + seen: set[str] = set() + + for row in checked: + if row.get("kind") != "mobile": + continue + if row.get("alive") == "dead": + continue + phone = str(row.get("phone") or "") + if len(phone) == 11 and phone.startswith("7") and phone not in seen: + seen.add(phone) + picked.append({ + "phone": phone, + "operator": str(row.get("provider") or ""), + "phone_type": "mobile", + }) + + return picked +``` + +- [ ] **Шаг 4: Тест на стороне портала** + +```php +ingest([[ + 'firm_name' => 'ООО Ромашка', + 'firm_inn' => '2452108518', + 'phones' => [ + ['phone' => '79990000001', 'operator' => 'ВымпелКом', 'phone_type' => 'mobile'], + ], + ]]); + + $row = SalesAdAudiencePhone::where('phone', '79990000001')->first(); + + expect($row->operator)->toBe('ВымпелКом') + ->and($row->phone_type)->toBe('mobile'); +}); + +it('принимает и старый формат — голый список номеров', function () { + (new AdAudienceIntake)->ingest([[ + 'firm_name' => 'ООО Старая', + 'firm_inn' => '2452108519', + 'phones' => ['79990000002'], + ]]); + + $row = SalesAdAudiencePhone::where('phone', '79990000002')->first(); + + expect($row)->not->toBeNull() + ->and($row->operator)->toBeNull(); +}); +``` + +- [ ] **Шаг 5: Правка приёма** + +В `AdAudienceIntake::ingest()` заменить цикл по номерам (строки 72–88) на: + +```php +foreach ($rawPhones as $rawPhone) { + // Два формата разом: новый — словарь с оператором, старый — голая строка. + // Python-служба переезжает не мгновенно, ломать приём нельзя. + $phone = is_array($rawPhone) ? (string) ($rawPhone['phone'] ?? '') : (string) $rawPhone; + + if ($phone === '') { + continue; + } + + $row = SalesAdAudiencePhone::firstOrNew(['phone' => $phone]); + $row->fill([ + 'firm_id' => $firm->id, + 'firm_inn' => $firm->firm_inn, + 'firm_name' => $firm->firm_name, + 'city' => $firm->city, + 'rubric' => $firm->rubric, + 'expires_at' => $expiresAt, + 'state' => 'active', + 'removed_at' => null, + 'synced_at' => null, + ]); + + if (is_array($rawPhone)) { + $row->operator = $rawPhone['operator'] ?? null; + $row->phone_type = $rawPhone['phone_type'] ?? null; + } + + $row->added_at ??= now(); + $row->save(); + $phones++; +} +``` + +Также ослабить типовую подсказку выше: `/** @var array $rawPhones */`. + +- [ ] **Шаг 6: Убедиться, что зелено** + +Run: `cd моя/sales-finder && python -m pytest tests/test_adaudience.py -v` +Expected: PASS, 2 теста. + +Run: `cd app && composer test -- --filter=AdAudienceIntakeOperatorTest` +Expected: PASS, 2 теста. + +- [ ] **Шаг 7: Коммит** + +⚠️ Каталог `моя/` в `.gitignore` — `git add` пропустит файлы **молча**. Нужен `-f` +и по одному пути. + +```bash +git add app/app/Services/Sales/AdAudienceIntake.php app/tests/Feature/Sales/AdAudienceIntakeOperatorTest.php +git add -f "моя/sales-finder/salesfinder/adaudience.py" "моя/sales-finder/tests/test_adaudience.py" +git status --short +git commit -m "feat(смс): оператор номера доезжает из поиска клиентов в прогрев" +``` + +--- + +## Task 12: Полный прогон и сверка + +- [ ] **Шаг 1: Весь набор тестов** + +Run: `cd app && composer test` +Expected: PASS, ноль падений. Сверить, что число файлов выросло ровно на добавленные. + +Run: `cd app && npm run test:vue` +Expected: PASS. + +⚠️ Смотреть не только «зелено», но и **счётчик файлов**. Если файл не загрузился +из-за синтаксической ошибки, набор покажет «N failed» при нуле упавших тестов — +это значит, что файл не прочитался, а не что всё хорошо. + +- [ ] **Шаг 2: Статанализ** + +Run: `cd app && composer stan` +Expected: 0 ошибок. Если список игнора отстал — пересобрать его, **не глушить хук**. + +- [ ] **Шаг 3: Живая проверка в песочнице** + +Локально: создать рассылку на 2 номера вида `7999000000X`, отправить, открыть журнал. +Проверить: статус `fake_sent`, цена 0, на экране видна пометка песочницы. + +- [ ] **Шаг 4: Обновить спеку** + +В спеке отметить, что модуль реализован, и перенести открытые вопросы СМС-1…СМС-4 +в актуальное состояние по итогам звонков операторов. + +--- + +## Что дальше (в этот заход НЕ входит) + +- живые провайдеры МТС и Билайн — после согласования имени отправителя; +- приём статусов доставки колбэком; +- автоотписка по входящему «СТОП»; +- личный кабинет клиента, тарифы и наценка; +- выкат на бой — **только с разрешения владельца**. diff --git a/docs/superpowers/specs/2026-07-20-sms-progrev-modul-design.md b/docs/superpowers/specs/2026-07-20-sms-progrev-modul-design.md new file mode 100644 index 00000000..a2ebebc6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-sms-progrev-modul-design.md @@ -0,0 +1,352 @@ +# Модуль СМС-рассылки «Прогрев СМС» — дизайн + +**Дата:** 20.07.2026 +**Статус:** согласован владельцем +**Владелец решения:** Дмитрий (заказчик) + +--- + +## 1. Зачем + +Начальник отдела продаж греет директоров-кандидатов рекламой (Яндекс, ВК, Telegram через МТС). +Реклама показывается, но обратиться к человеку напрямую нечем: **отправки СМС в проекте нет +вообще** — ни у СМС-центра (там только баланс и HLR-проверка), ни у МТС (там только выгрузка +файла руками). + +Модуль даёт начальнику отправить СМС по отмеченным фирмам из раздела прогрева: свой текст, +свой список, видимая цена до отправки, журнал после. + +**Горизонт:** сейчас — под себя. Дальше — услуга клиентам Лидерры за деньги. Поэтому +мультиклиентность закладывается в структуру данных с первого дня, но интерфейс клиента, +тарифы и наценка в этот заход НЕ входят (см. §9). + +--- + +## 2. Что уже есть и переиспользуется + +| Что | Где | Как используем | +|---|---|---| +| Номера прогрева | `sales_ad_audience_phones` (`pgsql_supplier`) | источник получателей | +| Фирмы прогрева | `sales_ad_audience_firms`, скоупы `forYandex/forVk/forMts` | отбор и группировка | +| Экран начальника | `app/resources/js/views/sales/SalesAdAudienceView.vue` | рядом появится новый раздел | +| Проверка «начальник, не менеджер» | приватный `denyIfNotHead()` в Sales-контроллерах | копируем тот же приём | +| Оператор и тип номера (уже оплачены) | `моя/sales-finder/salesfinder/phonecheck.py` — поля `provider`, `kind` | проводим через передачу, см. §3.2 | +| Списание денег за услугу | `App\Services\Autopodbor\AutopodborChargeService::chargeForRun()` | **образец для копирования** | +| Журнал операций с деньгами | `balance_transactions` (партиционирована, hash chain) | запись списаний | +| Каркас надёжного джоба | `App\Jobs\RouteSupplierLeadJob` (`$tries`, `$backoff`, `failed()`, структурные логи) | образец | +| Баланс СМС-центра на плитке | `App\Services\External\SmscBalanceProvider` | уже показывает деньги провайдера | + +**Ничего из перечисленного не переписываем.** + +--- + +## 3. Ограничения, влияющие на устройство + +### 3.1. Два соединения, JOIN невозможен + +`sales_*` живут на `pgsql_supplier` без RLS (внутренний инструмент отдела продаж, тенантов +там нет). `tenants` и биллинг — на `pgsql` под RLS. **JOIN между ними физически невозможен**, +сопоставление делается в PHP — ровно как уже сделано для `prospect_id`. + +Следствие: `tenant_id` в СМС-таблицах — обычная колонка **без внешнего ключа**, разрешается +в коде. + +### 3.2. Оператор известен и уже оплачен — теряется только при передаче + +Оператор и тип номера **уже определены и сохранены** в Поиске клиентов: +`моя/sales-finder/salesfinder/phonecheck.py` кладёт по каждому номеру `kind` +(`mobile` / `landline` / `junk` / `unknown`) и `provider` (короткое имя: «ВымпелКом», +«Т2 Мобайл», «МТС»). Это видно в колонке «Телефон (контакт)» на экране списка. + +Теряется значение **на передаче в портал**: `salesfinder/adaudience.py::pick_ad_phones()` +возвращает `list[str]` — голые номера, отбрасывая `provider`. В `sales_ad_audience_phones` +колонки под оператора просто нет. + +Следствие: **повторно платить ДаДате не нужно.** Нужно провести уже известное значение +через передачу — вернуть из `pick_ad_phones()` пару «номер + оператор», принять её в +`AdAudienceIntake` и сохранить в новую колонку. + +**Городских номеров в прогреве не бывает по построению:** `pick_ad_phones()` берёт только +`kind == "mobile"` и отбрасывает `alive == "dead"`. Проверка `skipped_not_mobile` в модуле +остаётся как страховка на случай номеров, попавших мимо этого пути. + +### 3.3. Имя отправителя — внешняя блокировка + +Без согласованного оператором имени отправителя рекламная СМС не уходит. Согласование — +недели, у каждого оператора отдельно, у части операторов имя платное (2–3 тыс ₽/мес). +Модуль обязан **работать в режиме «без имени»** (заглушка/песочница), иначе разработку +нельзя ни закончить, ни проверить до окончания модерации. + +### 3.4. Экономика на малом объёме + +Абонплата за имена (9–11 тыс ₽/мес за четыре сети) на объёме ~177 номеров превышает +стоимость самой рассылки (~1,5 тыс ₽) в разы. Поэтому в первой версии поддерживаются +**два канала: МТС и Билайн** (у обоих имя бесплатное). Мегафон, Теле2 и агрегатор +подключаются позже — без переписывания модуля (см. §4.2). + +--- + +## 4. Устройство + +### 4.1. Слои + +``` +Экран «Прогрев СМС» (Vue) + │ + SalesSmsController ← проверка «начальник», подсчёт цены, запуск + │ + SmsCampaignService ← сборка получателей, стоп-лист, дедуп, расчёт цены + │ + SendSmsCampaignJob ← очередь, ретраи, журнал + │ + SmsRouter ← выбирает провайдера по оператору номера + │ + SmsProvider (интерфейс) ← MtsSmsProvider │ BeelineSmsProvider │ SmscSmsProvider │ FakeSmsProvider +``` + +Каждый слой тестируется отдельно. Провайдер добавляется одним файлом + строкой в конфиг. + +### 4.2. Интерфейс провайдера + +```php +interface SmsProvider +{ + public function key(): string; // 'mts' | 'beeline' | 'smsc' | 'fake' + + /** Каких операторов умеет обслуживать. ['*'] — всех. */ + public function servesOperators(): array; + + /** Цена одного сегмента в копейках для данного оператора. */ + public function priceKopecks(string $operator): int; + + /** Отправка одного сообщения. Бросает SmsSendException при отказе. */ + public function send(SmsOutgoing $message): SmsSendResult; +} +``` + +`SmsSendResult`: `providerMessageId`, `segments`, `costKopecks`, `acceptedAt`. + +**`FakeSmsProvider`** — обязателен. Ничего никуда не отправляет, пишет в журнал со статусом +`fake_sent`. Это единственный способ довести модуль до конца и проверить экраны, пока имя +отправителя на модерации. Включается настройкой, на бою по умолчанию выключен. + +### 4.3. Маршрутизация + +`SmsRouter` для каждого номера: + +1. смотрит `operator` номера; +2. выбирает среди включённых провайдеров того, кто обслуживает этого оператора и дешевле; +3. если оператор неизвестен — берёт провайдера-универсала (`['*']`); +4. если такого нет — номер помечается `skipped_no_route`, деньги не тратятся. + +Так билайновский номер уходит через Билайн за 4,75 ₽, а прочие через МТС — **без ручного +выбора**. + +### 4.4. Что НЕ отправляется никогда + +Проверки до списания денег, каждая со своим статусом в журнале: + +| Причина | Статус | +|---|---| +| номер в стоп-листе | `skipped_optout` | +| тип номера не мобильный | `skipped_not_mobile` | +| номер погашен (`removed_at`) или не активен | `skipped_inactive` | +| дубль внутри одной кампании | `skipped_duplicate` | +| нет маршрута | `skipped_no_route` | +| не хватило денег | `skipped_no_funds` | + +--- + +## 5. Данные + +Все таблицы — на `pgsql_supplier`, без RLS, как остальные `sales_*`. +Во всех — `tenant_id BIGINT NULL` (NULL = «Лидерра сама»), без FK, разрешается в PHP. + +### `sales_sms_senders` — имена отправителей + +| Колонка | Тип | Смысл | +|---|---|---| +| `id` | BIGSERIAL PK | | +| `tenant_id` | BIGINT NULL | владелец имени | +| `name` | VARCHAR(11) | само имя, напр. `liderra.ru` | +| `provider_key` | VARCHAR(20) | у какого провайдера зарегистрировано | +| `status` | VARCHAR(20) | `draft` / `pending` / `active` / `rejected` | +| `monthly_fee_kopecks` | INTEGER NULL | абонплата, для отчётности | +| `submitted_at`, `approved_at`, `rejected_reason` | | ход модерации | + +UNIQUE (`tenant_id`, `name`, `provider_key`). + +### `sales_sms_campaigns` — рассылки + +| Колонка | Тип | Смысл | +|---|---|---| +| `id` | BIGSERIAL PK | | +| `tenant_id` | BIGINT NULL | | +| `sender_id` | BIGINT NULL → `sales_sms_senders` | от чьего имени | +| `title` | VARCHAR(200) | как назвал начальник | +| `body` | TEXT | текст сообщения | +| `status` | VARCHAR(20) | `draft` / `queued` / `sending` / `done` / `failed` / `canceled` | +| `created_by` | BIGINT | `sales_users.id` | +| `planned_count`, `sent_count`, `failed_count`, `skipped_count` | INTEGER | итоги | +| `estimated_cost_kopecks`, `actual_cost_kopecks` | INTEGER | цена до и после | +| `balance_transaction_id` | BIGINT NULL | защита от двойного списания | +| `started_at`, `finished_at`, `last_error` | | | + +### `sales_sms_messages` — журнал по каждому номеру + +| Колонка | Тип | Смысл | +|---|---|---| +| `id` | BIGSERIAL PK | | +| `campaign_id` | BIGINT → campaigns ON DELETE CASCADE | | +| `phone_id` | BIGINT NULL | ссылка на `sales_ad_audience_phones` | +| `phone` | VARCHAR(11) | снимок номера на момент отправки | +| `operator`, `phone_type` | VARCHAR(30) / VARCHAR(12) | снимок | +| `provider_key` | VARCHAR(20) | кто отправлял | +| `status` | VARCHAR(24) | `pending` / `sent` / `delivered` / `failed` / `fake_sent` / `skipped_*` | +| `segments` | SMALLINT | сколько кусков | +| `cost_kopecks` | INTEGER | сколько стоило | +| `provider_message_id` | VARCHAR(64) NULL | для сверки статуса | +| `sent_at`, `delivered_at`, `error` | | | + +UNIQUE (`campaign_id`, `phone`) — дубль внутри кампании невозможен на уровне БД. + +### `sales_sms_optouts` — стоп-лист + +| Колонка | Тип | Смысл | +|---|---|---| +| `id` | BIGSERIAL PK | | +| `tenant_id` | BIGINT NULL | | +| `phone` | VARCHAR(11) | | +| `reason` | VARCHAR(30) | `manual` / `reply_stop` / `complaint` / `operator` | +| `created_at` | TIMESTAMPTZ | | + +UNIQUE (`tenant_id`, `phone`). **Проверяется перед каждой отправкой, без исключений.** + +### Правки существующего + +`sales_ad_audience_phones` + две колонки (значения приходят из Поиска клиентов, ДаДата +повторно не дёргается): + +- `operator VARCHAR(30) NULL` — «ВымпелКом» / «МТС» / «МегаФон» / «Т2 Мобайл», как отдаёт + `phonecheck.py` +- `phone_type VARCHAR(12) NULL` — `mobile` / `landline` / `junk` / `unknown` (значения + `kind` из поисковика, без переименования — чтобы не заводить второй словарь) + +Плюс правка на стороне Поиска клиентов: `salesfinder/adaudience.py::pick_ad_phones()` +возвращает не `list[str]`, а список пар «номер + оператор + тип»; `AdAudienceIntake` +принимает и сохраняет их. Для номеров, залитых ранее, колонки остаются пустыми — такие +уходят через провайдера-универсала. + +`balance_transactions.type` — расширение CHECK новым значением `sms_charge` +(образцы миграций расширения уже есть в проекте). + +--- + +## 6. Деньги + +Копируется приём из `AutopodborChargeService`: + +1. всё в `DB::transaction`; +2. `SELECT ... FOR UPDATE` строки кампании; +3. guard `if ($locked->balance_transaction_id !== null) return;` — второй запуск не спишет; +4. сравнение и вычитание через bcmath, не через float; +5. не хватило — `InsufficientBalanceException`, кампания в `failed`, ни одна СМС не ушла; +6. запись `BalanceTransaction` с `type = sms_charge`, `related_type = SalesSmsCampaign::class`; +7. обратная ссылка `campaign->balance_transaction_id = $tx->id`. + +**Списание — до отправки, по расчётной цене.** После отправки разница между расчётом и фактом +записывается в `actual_cost_kopecks`; выравнивание баланса — за пределами этого захода +(см. §9), расхождение видно в журнале. + +Пока `tenant_id` пуст (мы сами), списание с баланса **не производится** — платим оператору +напрямую. Механизм пишется сразу, но включается флагом, когда появится первый клиент. + +--- + +## 7. Экран + +Новый раздел в кабинете начальника, рядом с «Рекламной аудиторией». + +**Список рассылок:** название, когда, сколько ушло, сколько дошло, сколько стоило, статус. + +**Создание:** + +1. отбор получателей — те же фильтры, что в прогреве (фирма, город, стадия), с галочками; +2. поле текста со счётчиком: символов, сегментов, **итоговой цены прямо под полем**; +3. строка-предупреждение: «из 177 отобранных уйдёт 129 — 31 городской, 12 в стоп-листе, + 5 без маршрута»; +4. кнопка «Отправить», подтверждение с ценой; +5. после запуска — прогресс и журнал по каждому номеру. + +**Правила экрана:** + +- цена показывается **до** отправки, всегда; +- если имя отправителя не `active` — кнопка отправки заблокирована с объяснением, какого + именно имени не хватает; +- режим песочницы помечается на экране явно, чтобы «отправил» нельзя было спутать с настоящей + отправкой. + +--- + +## 8. Ошибки и надёжность + +- джоб: `$tries = 3`, `$backoff = 60`, `failed()` пишет `sms.campaign_failed_permanently`; +- отказ по одному номеру не роняет кампанию — статус пишется в строку, работа продолжается; +- терминальные отказы провайдера (неверный номер, запрещённый текст) **не ретраятся**; +- повторный запуск кампании безопасен: `UNIQUE (campaign_id, phone)` + guard по + `balance_transaction_id`; +- все логи структурные, ключи вида `sms.<событие>`; +- **секреты провайдеров — только в `.env`**, в репозиторий не попадают. + +--- + +## 9. Чего в этом заходе НЕТ + +Осознанно отложено, места оставлены: + +- личный кабинет клиента и самостоятельная отправка клиентом; +- тарифы, наценка, выставление счетов за СМС; +- выравнивание баланса при расхождении расчёта и факта; +- приём статусов доставки колбэком от оператора (в первой версии статус — «принято + провайдером»); +- приём входящих СМС и автоматическая отписка по ответу «СТОП» + (стоп-лист пополняется руками); +- Мегафон, Теле2 и агрегатор как провайдеры; +- планировщик отложенных рассылок. + +--- + +## 10. Как проверяем + +- **Юнит:** маршрутизация (какой провайдер для какого оператора), расчёт сегментов и цены, + все шесть причин пропуска, идемпотентность списания. +- **Feature:** менеджеру 403, начальнику 200; кампания не стартует без активного имени; + стоп-лист исключает номер; повторный запуск не списывает дважды; нехватка денег не + отправляет ничего. +- **Фронт:** счётчик сегментов и цены, блокировка кнопки без имени, видимость режима песочницы. +- **Живая проверка:** первая настоящая СМС — на номер владельца, одна штука, с записью + в журнал. Только после этого рассылка по списку. + +**Защиту проверяем вырезанием защиты:** убрать проверку стоп-листа — тест обязан покраснеть. + +--- + +## 11. Открытые вопросы (не блокируют начало работы) + +- **СМС-1.** Разрешают ли Билайн и МТС отправку от имени клиентов (перепродажу услуги)? + Вопрос менеджерам при звонке. От ответа зависит, годятся ли прямые договоры для клиентской + версии или под неё нужен агрегатор. +- **СМС-2.** Действует ли бесплатное имя МТС при отправке на чужие сети, или там появляется + абонплата? +- **СМС-3.** Есть ли у Теле2 рассылка по своей загруженной базе (страница услуги мертва, + живёт только таргет по базе оператора)? +- **СМС-4.** Точная цена имени у Мегафона — источники расходятся (2000 против 3000 ₽/мес). + +--- + +## 12. Ссылки + +- Заявки поданы 20.07.2026: Билайн №2300074097, Мегафон (принята), Теле2 (принята). +- Разрешение на домен для имени отправителя: `моя/Разрешение-на-домен-liderra.md` + (в git не попадает — там паспортные данные). +- План работ: `docs/superpowers/plans/2026-07-20-sms-progrev-modul.md` diff --git a/моя/sales-finder/salesfinder/adaudience.py b/моя/sales-finder/salesfinder/adaudience.py index 7839aefb..0b6c6670 100644 --- a/моя/sales-finder/salesfinder/adaudience.py +++ b/моя/sales-finder/salesfinder/adaudience.py @@ -7,9 +7,17 @@ from typing import Any -def pick_ad_phones(firm: dict[str, Any]) -> list[str]: +def pick_ad_phones(firm: dict[str, Any]) -> list[dict[str, str]]: + """Отдаёт не голый номер, а «номер + оператор + тип». + + Оператор уже определён и ОПЛАЧЕН ДаДате при проверке номеров (phonecheck.py → + provider/kind). Раньше он терялся ровно здесь, на передаче в портал, и + маршрутизация СМС не могла выбрать дешёвый канал. Повторно платить не нужно — + достаточно провести уже известное значение через передачу. + """ checked = firm.get("contact_phones_checked") or [] - picked: list[str] = [] + picked: list[dict[str, str]] = [] + seen: set[str] = set() for row in checked: if row.get("kind") != "mobile": @@ -17,7 +25,12 @@ def pick_ad_phones(firm: dict[str, Any]) -> list[str]: if row.get("alive") == "dead": continue phone = str(row.get("phone") or "") - if len(phone) == 11 and phone.startswith("7") and phone not in picked: - picked.append(phone) + if len(phone) == 11 and phone.startswith("7") and phone not in seen: + seen.add(phone) + picked.append({ + "phone": phone, + "operator": str(row.get("provider") or ""), + "phone_type": "mobile", + }) return picked diff --git a/моя/sales-finder/tests/test_ad_payload_contract.py b/моя/sales-finder/tests/test_ad_payload_contract.py new file mode 100644 index 00000000..0c385225 --- /dev/null +++ b/моя/sales-finder/tests/test_ad_payload_contract.py @@ -0,0 +1,101 @@ +"""Договор посылки в прогрев: где словари, а где голые строки (Task 11). + +В одной посылке в портал живут ДВА РАЗНЫХ формата телефона, и путать их нельзя: + + • верхний "phones" — СЛОВАРИ {phone, operator, phone_type}. Оператор уже + определён и оплачен ДаДате (phonecheck.py → provider), без него маршрутизация + СМС не может выбрать дешёвый канал; + • "contacts[].phones" — ГОЛЫЕ СТРОКИ. Это установленный договор всего проекта: + app/Http/Controllers/Api/Sales/SalesProspectController.php требует + 'contacts.*.phones.*' => ['nullable','string']. + +🔴 Зачем этот файл. Приём прогрева (SalesIntegrationController) валидирует contacts +только как array и ВНУТРЬ НЕ СМОТРИТ. Значит словарь, попавший в contacts, ляжет в +базу молча, разойдётся с остальными записями и рванёт потом в чужом месте — там, где +кто-то прочитает contacts[].phones[0] и получит словарь вместо номера. +Тесты ниже держат обе стороны, чтобы следующий человек не сломал это снова. + +ПДн: только фиктивные номера 7999000000X. +""" +import os + +from fastapi.testclient import TestClient + +from salesfinder.models import Firm + + +def make_client(tmp_path): + os.environ["SF_DB"] = str(tmp_path / "runs.sqlite") + from web import app as appmod + appmod._portal_auth = lambda: None + return TestClient(appmod.app), appmod + + +def _firm(name="ООО Ромашка", **over): + base = dict( + name=name, inn="7709918259", city="Москва", domain="romashka.ru", + director="Иванов И.И.", hotness=90, + contact_phones_checked=[ + {"phone": "79990000001", "kind": "mobile", "alive": "alive", + "provider": "ВымпелКом"}, + ], + ) + base.update(over) + return Firm(**base) + + +def _seed(appmod, firms): + rid = appmod.STORE.create("stom", "moscow") + appmod.STORE.save_result(rid, firms) + appmod.STORE.set_status(rid, "done") + return rid + + +def _capture(appmod): + sent = {} + + def stub_send(base, token, firms): + sent["firms"] = firms + return {"added": len(firms), "renewed": 0, "phones": 1} + + appmod.PORTAL_SEND_AD_AUDIENCE = stub_send + return sent + + +# ── одиночный прогрев (web/app.py) ────────────────────────────────────────── + +def test_одиночный_прогрев_наверх_словари_а_в_contacts_строки(tmp_path): + c, appmod = make_client(tmp_path) + appmod._portal_cfg = lambda: ("https://p", "TOK") + sent = _capture(appmod) + rid = _seed(appmod, [_firm()]) + + c.post(f"/view/{rid}/warmup", data={"sel": ["0"]}) + + firm = sent["firms"][0] + assert firm["phones"] == [ + {"phone": "79990000001", "operator": "ВымпелКом", "phone_type": "mobile"}, + ] + assert firm["contacts"] == [{"name": "Иванов И.И.", "phones": ["79990000001"]}] + # ↓ то, ради чего файл: внутри contacts не должно оказаться ни одного словаря + for contact in firm["contacts"]: + assert all(isinstance(p, str) for p in contact["phones"]) + + +# ── групповой прогрев (web/bulk.py) ───────────────────────────────────────── + +def test_групповой_прогрев_наверх_словари_а_в_contacts_строки(tmp_path): + c, appmod = make_client(tmp_path) + appmod._portal_cfg = lambda: ("https://p", "TOK") + sent = _capture(appmod) + rid = _seed(appmod, [_firm()]) + + c.post("/bulk/warmup", data={"ids": [rid], "channels": ["yandex"]}) + + firm = sent["firms"][0] + assert firm["phones"] == [ + {"phone": "79990000001", "operator": "ВымпелКом", "phone_type": "mobile"}, + ] + assert firm["contacts"] == [{"name": "Иванов И.И.", "phones": ["79990000001"]}] + for contact in firm["contacts"]: + assert all(isinstance(p, str) for p in contact["phones"]) diff --git a/моя/sales-finder/tests/test_adaudience.py b/моя/sales-finder/tests/test_adaudience.py index 9c3400e2..2fb2f77c 100644 --- a/моя/sales-finder/tests/test_adaudience.py +++ b/моя/sales-finder/tests/test_adaudience.py @@ -27,20 +27,45 @@ def test_берём_только_живые_мобильные(): ] } - assert pick_ad_phones(firm) == ["79990000001"] + # Task 11: отбор прежний, но отдаётся «номер + оператор», а не голая строка. + assert pick_ad_phones(firm) == [ + {"phone": "79990000001", "operator": "", "phone_type": "mobile"}, + ] def test_номер_гасшего_абонента_всё_равно_годится(): # «выключен» — не «не существует»: рекламу такому показать можно. firm = {"contact_phones_checked": [{"phone": "79990000005", "kind": "mobile", "alive": "gone"}]} - assert pick_ad_phones(firm) == ["79990000005"] + assert pick_ad_phones(firm) == [ + {"phone": "79990000005", "operator": "", "phone_type": "mobile"}, + ] def test_фирма_без_размеченных_номеров_даёт_пустой_список(): assert pick_ad_phones({}) == [] +# ── Task 11: оператор номера уже оплачен ДаДате — довозим его до портала ───── + +def test_отдаёт_оператора_вместе_с_номером(): + firm = {"contact_phones_checked": [ + {"phone": "79990000001", "kind": "mobile", "alive": "alive", "provider": "ВымпелКом"}, + ]} + + assert pick_ad_phones(firm) == [ + {"phone": "79990000001", "operator": "ВымпелКом", "phone_type": "mobile"}, + ] + + +def test_городской_по_прежнему_не_проходит(): + firm = {"contact_phones_checked": [ + {"phone": "74990000001", "kind": "landline", "alive": "alive", "provider": ""}, + ]} + + assert pick_ad_phones(firm) == [] + + # ── Шаг 5: отправка в портал (транспорт) ──────────────────────────────────── class _Resp: @@ -129,7 +154,10 @@ def test_warmup_sends_picked_firms_with_their_phones(tmp_path): assert r.status_code == 200 assert len(sent["firms"]) == 2 assert sent["firms"][0]["firm_name"] == "A" - assert sent["firms"][0]["phones"] == ["79990000001"] + # Task 11: в портал уезжает «номер + оператор», а не голая строка. + assert sent["firms"][0]["phones"] == [ + {"phone": "79990000001", "operator": "", "phone_type": "mobile"}, + ] assert sent["firms"][0]["firm_inn"] == "7709918259" diff --git a/моя/sales-finder/web/app.py b/моя/sales-finder/web/app.py index 6169ff12..6a41551b 100644 --- a/моя/sales-finder/web/app.py +++ b/моя/sales-finder/web/app.py @@ -444,7 +444,14 @@ def send_to_warmup(rid: int, sel: list = Form(default=[]), channels: str = "yand if not phones: without_phone.append(f.name) continue - contacts = [{"name": f.director, "phones": phones}] if f.director else [] + # Два разных договора, не путать их между собой: + # верхний "phones" — словари «номер + оператор» (нужны маршрутизации СМС); + # "contacts[].phones" — ГОЛЫЕ СТРОКИ, как во всём проекте + # (SalesProspectController: 'contacts.*.phones.*' => ['nullable','string']). + # Приём прогрева внутрь contacts не смотрит, так что словарь лёг бы в базу + # молча и разошёлся бы с остальными записями — рвануло бы потом в чужом месте. + contacts = ([{"name": f.director, "phones": [p["phone"] for p in phones]}] + if f.director else []) payloads.append({ "firm_name": f.name, "firm_inn": f.inn, diff --git a/моя/sales-finder/web/bulk.py b/моя/sales-finder/web/bulk.py index e570a12b..1c601ebe 100644 --- a/моя/sales-finder/web/bulk.py +++ b/моя/sales-finder/web/bulk.py @@ -183,7 +183,11 @@ def _ad_phones(f): Городской или мёртвый номер в рекламной аудитории — выброшенные деньги и испорченный охват. Фирмы, собранные до появления проверки номеров, разметки не имеют — они честно считаются «без годного мобильного» и ждут пересбора контактов, - а не едут в рекламу вслепую.""" + а не едут в рекламу вслепую. + + Отдаёт СЛОВАРИ «номер + оператор + тип» (Task 11) — оператор уже оплачен ДаДате + и нужен маршрутизации СМС. В contacts[].phones такой словарь класть НЕЛЬЗЯ, + там договор проекта — голые строки.""" from dataclasses import asdict from salesfinder.adaudience import pick_ad_phones @@ -228,7 +232,10 @@ def bulk_warmup(ids: list = Form(default=[]), channels: list = Form(default=[])) "site": f.domain, "phone": f.phone, "rating_label": heat_label(f.hotness or 0), "payload": asdict(f), - "contacts": [{"name": f.director, "phones": phones}] if f.director else [], + # contacts[].phones — голые строки (договор проекта), верхний + # phones — словари «номер + оператор». Подробности в web/app.py. + "contacts": ([{"name": f.director, "phones": [p["phone"] for p in phones]}] + if f.director else []), "phones": phones, **channel_flags(chans), }) ready.append(f)