From f25c2f550d7c2c8986aedc303aa850259b3d70ce 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: Wed, 29 Jul 2026 11:30:41 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D1=81=D0=BC=D1=81-=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82):=20=D0=B8=D0=BC=D1=8F,=20=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D1=91=D0=BD=D0=BD=D0=BE=D0=B5=20=D0=B7=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=BB=D0=B3,=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80?= =?UTF-8?q?=D0=B0=D1=89=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D1=81=D0=B0=D0=BC?= =?UTF-8?q?=D0=BE=20=E2=80=94=20=D0=B7=D0=B0=20=D0=BE=D0=B4=D0=B8=D0=BD=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=8F=D1=86=20=D0=B2=D0=BF=D0=B5=D1=80=D1=91?= =?UTF-8?q?=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Строка листа 4.4, Этап 4 Task 3. Раньше ночной работник смотрел только на работающие имена, поэтому имя, отключённое за долг, не возвращалось само никогда и ни при каких деньгах. Теперь у него есть второй проход: как только у клиента хватило СВОБОДНЫХ денег (с учётом замороженных под рассылки), имя включается, а плата берётся за один месяц вперёд от сегодняшнего дня. Старый долг прощается — решение владельца В-133: за месяцы, когда имя не работало, брать не за что. Документы заново не запрашиваются: заявка не пересоздаётся, меняются только состояние и срок оплаты. Сумма нигде не зашита — берётся из карточки имени. Она уже считается как «цена за оператора × число операторов», и когда подключим остальных операторов, вырастет сама. Плана оказалось мало (журнал В-140). Он предлагал возвращать всё, что «отключено и с долгом», но имя отключает и владелец руками из админки — и такое имя погашено у МТС, оно не работает. Портал включал бы его обратно и списывал за это деньги клиента, отменяя решение владельца. Отличать по тексту записки нельзя: текст — не признак. Поэтому у имени появилась графа «почему отключено» (за долг / рукой владельца); сам портал возвращает только первое. Схема v9.18, миграция 2026_08_01_100600, прав не требует. Второй правкой плана (В-141) переписан его тест на двойное списание: он проходил вхолостую, потому что после первого прогона имя уже работает и второй проход его не видит. Настоящая опасность — кнопка клиента (строка 4.5) и работник спишут за один месяц дважды, если ключ у них разный. Тест теперь про это и краснеет от порчи ключа. Проверено вырезанием, четыре выреза, все вернуты: — убрал проверку денег → покраснели четыре теста; — убрал отбор по причине отключения → покраснел тест про имя владельца; — испортил ключ месяца → списание прошло дважды, тест покраснел; — убрал пометку причины из админки → покраснел тест админки. Живой прогон под боевой ролью crm_app_user, пара «не произошло / произошло»: на счету 50 ₽ при плате 100 — имя осталось отключённым, деньги не тронуты; пополнил до 550 ₽ — имя работает, списано ровно 100 ₽, оплачено до 29.08, на счету 450 ₽. В том же прогоне имя, выключенное владельцем, при 5000 ₽ на счету осталось выключенным. Вторая пара — на пометку клиента: без неё имя не включается вовсе, и работник в обоих случаях молчит. Прогон вскрыл чужую мину (В-142): у боевой роли не было права на счётчик номеров таблицы движений рекламного кошелька — под этой ролью молча не проходило ни одно списание. Это расхождение локального стенда с эталоном db/02_grants.sql, кода не касается; в памятку на выкат вписана читающая проверка этого права на бою. Прогоны: клиентские СМС 262/262 (11 пачек, все с первой попытки), приём лидов 17/17, phpstan ровно 2 чужие давние, pint чисто. Фронт не трогался. --- .../Api/Admin/AdminSmsSenderController.php | 4 + app/app/Jobs/ChargeSmsNameFeeJob.php | 91 ++++++++++++ app/app/Models/ClientSmsSender.php | 9 ++ ...suspended_reason_to_client_sms_senders.php | 49 +++++++ .../Feature/ClientSms/AdminSenderTest.php | 24 ++++ .../Feature/ClientSms/NameFeeJobTest.php | 131 ++++++++++++++++++ db/CHANGELOG_schema.md | 33 +++++ 7 files changed, 341 insertions(+) create mode 100644 app/database/migrations/2026_08_01_100600_add_suspended_reason_to_client_sms_senders.php diff --git a/app/app/Http/Controllers/Api/Admin/AdminSmsSenderController.php b/app/app/Http/Controllers/Api/Admin/AdminSmsSenderController.php index 1c1097b1..7c582f03 100644 --- a/app/app/Http/Controllers/Api/Admin/AdminSmsSenderController.php +++ b/app/app/Http/Controllers/Api/Admin/AdminSmsSenderController.php @@ -137,6 +137,10 @@ class AdminSmsSenderController extends Controller $sender->update([ 'status' => ClientSmsSender::STATUS_SUSPENDED, 'note' => 'Отключено владельцем — погасить у МТС', + // Причина обязательна: без неё ночной работник, увидев отключённое имя + // с непогашенным долгом, включил бы его обратно за деньги клиента и + // отменил бы это решение (журнал В-140). + 'suspended_reason' => ClientSmsSender::SUSPENDED_BY_OWNER, ]); }); diff --git a/app/app/Jobs/ChargeSmsNameFeeJob.php b/app/app/Jobs/ChargeSmsNameFeeJob.php index 5e1611a1..7d9844e2 100644 --- a/app/app/Jobs/ChargeSmsNameFeeJob.php +++ b/app/app/Jobs/ChargeSmsNameFeeJob.php @@ -107,6 +107,9 @@ class ChargeSmsNameFeeJob implements ShouldQueue 'status' => ClientSmsSender::STATUS_SUSPENDED, 'note' => 'Долг более '.$grace.' дней — имя отключено, погасить у МТС', 'debt_since' => $debtSince, + // Причина отключения — признак, а не текст записки: по ней + // второй проход поймёт, что это имя портал вправе вернуть сам (В-140). + 'suspended_reason' => ClientSmsSender::SUSPENDED_BY_DEBT, ]); // Сигнал для владельца: имя выключено за долг — у МТС его тоже @@ -130,5 +133,93 @@ class ChargeSmsNameFeeJob implements ShouldQueue ]); } } + + $this->resumeSuspended($wallet); + } + + /** + * Возврат имени, отключённого ЗА ДОЛГ (строка листа 4.4). + * + * Решение владельца В-133: берём ОДИН МЕСЯЦ ВПЕРЁД, старый долг прощаем. Довод + * владельца: все месяцы, что имя было отключено, оно НЕ РАБОТАЛО — брать за них + * значит брать за воздух. Поэтому и `paid_until` отсчитывается от СЕГОДНЯ, а не + * от протухшего прежнего значения: иначе клиент заплатил бы за месяц, который + * уже прошёл. + * + * 🪤 Возвращаем ТОЛЬКО отключённых за долг (В-140). Имя, погашенное владельцем + * вручную, у МТС не работает — включить его обратно, да ещё и списав деньги, + * значит отменить решение владельца. + * + * 🪤 Сумму берём из карточки имени (`monthly_fee_rub`), а НЕ из настроек и не из + * константы: она посчитана как «цена за оператора × число операторов», и когда + * подключим остальных операторов, вырастет сама. Зашитое число соврало бы молча. + * + * Ключ идемпотентности — по ТЕКУЩЕМУ месяцу. Тот же ключ берёт кнопка клиента + * «включить имя обратно» (строка 4.5): за один календарный месяц имя оплачивается + * один раз, кто бы его ни включал. + */ + private function resumeSuspended(AdWalletService $wallet): void + { + $today = Carbon::now(); + + $rows = DB::connection('pgsql_supplier')->table('client_sms_senders') + ->where('status', ClientSmsSender::STATUS_SUSPENDED) + ->where('suspended_reason', ClientSmsSender::SUSPENDED_BY_DEBT) + ->get(['id', 'tenant_id', 'monthly_fee_rub']); + + foreach ($rows as $row) { + $tenantId = (int) $row->tenant_id; + + try { + DB::transaction(function () use ($row, $tenantId, $today, $wallet): void { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + $fee = (string) $row->monthly_fee_rub; + + // Свободные деньги считаем ДО списания: charge при недоборе не бросает + // исключение, а прижимает баланс к нулю (В-41). Полагаться на try/catch + // тут нельзя — имя включилось бы, не будучи оплаченным. + $adWallet = AdWallet::where('tenant_id', $tenantId)->first(); + $free = $adWallet !== null + ? bcsub((string) $adWallet->balance_rub, (string) $adWallet->frozen_rub, 2) + : '0.00'; + + if (bccomp($free, $fee, 2) < 0) { + return; // денег нет — имя остаётся отключённым, ничего не трогаем + } + + $wallet->charge( + $tenantId, + 'sms', + 'sender', + (int) $row->id, + $fee, + "sms:sender:{$row->id}:{$today->format('Y-m')}", + ); + + ClientSmsSender::where('tenant_id', $tenantId)->whereKey($row->id)->update([ + 'status' => ClientSmsSender::STATUS_ACTIVE, + 'paid_until' => $today->copy()->addMonthNoOverflow()->toDateString(), + 'debt_since' => null, + 'suspended_reason' => null, + 'note' => null, + ]); + + // Сигнал владельцу: имя ожило — у МТС его надо включить обратно + // руками (авто-API нет, зеркало сигнала об отключении выше). + Log::info('client_sms.name_resumed_after_debt', [ + 'sender_id' => $row->id, + 'tenant_id' => $tenantId, + 'charged' => $fee, + ]); + }); + } catch (Throwable $e) { + Log::warning('ChargeSmsNameFeeJob: сбой возврата имени после долга', [ + 'sender_id' => $row->id, + 'tenant_id' => $row->tenant_id, + 'error' => $e->getMessage(), + ]); + } + } } } diff --git a/app/app/Models/ClientSmsSender.php b/app/app/Models/ClientSmsSender.php index 0e41894b..882242c7 100644 --- a/app/app/Models/ClientSmsSender.php +++ b/app/app/Models/ClientSmsSender.php @@ -26,6 +26,14 @@ class ClientSmsSender extends Model public const STATUS_CANCELLED = 'cancelled'; + // ПОЧЕМУ имя отключено — от этого зависит, вправе ли портал вернуть его сам + // (журнал В-140). По записке `note` отличать нельзя: текст — не признак. + /** Отключил ночной работник за долг → появились деньги, возвращаем сами (В-133). */ + public const SUSPENDED_BY_DEBT = 'debt'; + + /** Отключил владелец руками — имя погашено у МТС, сам портал его не воскрешает. */ + public const SUSPENDED_BY_OWNER = 'owner'; + // Виды имени = какой документ подтверждает право (требование МТС). public const TYPE_LEGAL = 'legal'; // название юрлица → ОГРН/налоговый учёт @@ -58,6 +66,7 @@ class ClientSmsSender extends Model 'approved_at', 'paid_until', 'debt_since', + 'suspended_reason', ]; protected function casts(): array diff --git a/app/database/migrations/2026_08_01_100600_add_suspended_reason_to_client_sms_senders.php b/app/database/migrations/2026_08_01_100600_add_suspended_reason_to_client_sms_senders.php new file mode 100644 index 00000000..3da7baca --- /dev/null +++ b/app/database/migrations/2026_08_01_100600_add_suspended_reason_to_client_sms_senders.php @@ -0,0 +1,49 @@ +string('suspended_reason', 16)->nullable(); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('client_sms_senders', 'suspended_reason')) { + Schema::table('client_sms_senders', function (Blueprint $table): void { + $table->dropColumn('suspended_reason'); + }); + } + } +}; diff --git a/app/tests/Feature/ClientSms/AdminSenderTest.php b/app/tests/Feature/ClientSms/AdminSenderTest.php index ee2e43d9..5361e0b9 100644 --- a/app/tests/Feature/ClientSms/AdminSenderTest.php +++ b/app/tests/Feature/ClientSms/AdminSenderTest.php @@ -190,3 +190,27 @@ test('approve на НЕ-pending имени → 422', function () { ->assertStatus(422) ->assertJsonPath('message', 'Имя не в статусе на рассмотрении'); }); + +test('4.4 — служебное отключение помечает имя как выключённое ВЛАДЕЛЬЦЕМ, а не за долг (В-140)', function () { + config(['services.sms.sandbox' => false]); + + $tenant = Tenant::factory()->create(); + $sender = ClientSmsSender::create([ + 'tenant_id' => $tenant->id, + 'name' => 'active.ru', + 'name_type' => ClientSmsSender::TYPE_WEBSITE, + 'status' => ClientSmsSender::STATUS_ACTIVE, + 'operators' => ['mts'], + 'monthly_fee_rub' => '2500.00', + 'paid_until' => '2026-04-25', + 'debt_since' => '2026-04-26', // долг есть — и он НЕ должен воскресить имя + ]); + + $this->postJson("/api/admin/sms/senders/{$sender->id}/disable")->assertStatus(200); + + $fresh = ClientSmsSender::find($sender->id); + expect($fresh->status)->toBe(ClientSmsSender::STATUS_SUSPENDED) + // Без этой пометки ночной работник включил бы имя обратно за деньги клиента, + // отменив решение владельца. Имя погашено у МТС и не работает. + ->and($fresh->suspended_reason)->toBe(ClientSmsSender::SUSPENDED_BY_OWNER); +}); diff --git a/app/tests/Feature/ClientSms/NameFeeJobTest.php b/app/tests/Feature/ClientSms/NameFeeJobTest.php index 78b9351b..9e672b23 100644 --- a/app/tests/Feature/ClientSms/NameFeeJobTest.php +++ b/app/tests/Feature/ClientSms/NameFeeJobTest.php @@ -41,6 +41,26 @@ function makeActiveSender(int $tenantId, ?string $paidUntil, string $fee = '100. ]); } +/** + * Отключённое имя. `$reason` — ПОЧЕМУ отключено: за долг (портал вправе вернуть + * его сам) или рукой владельца (портал возвращать не смеет, журнал В-140). + */ +function makeSuspendedSender(int $tenantId, string $reason, string $fee = '100.00'): ClientSmsSender +{ + return ClientSmsSender::create([ + 'tenant_id' => $tenantId, + 'name' => 'MyBrand', + 'name_type' => ClientSmsSender::TYPE_COMPANY, + 'status' => ClientSmsSender::STATUS_SUSPENDED, + 'operators' => ['mts'], + 'monthly_fee_rub' => $fee, + 'paid_until' => '2026-04-25', // не платили с апреля — три месяца долга + 'debt_since' => '2026-04-26', + 'suspended_reason' => $reason, + 'note' => 'Долг более 29 дней — имя отключено, погасить у МТС', + ]); +} + beforeEach(function () { Carbon::setTestNow('2026-07-25 10:00:00'); config(['services.sms.sandbox' => false]); @@ -159,6 +179,117 @@ it('does not double-charge when the whole job runs twice on the same day', funct ->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1); }); +it('4.4 — отключённое за долг имя включается само, когда появились деньги, и берём за ОДИН месяц', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test'); + $sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT); + + app(ChargeSmsNameFeeJob::class)->handle(); + + $sender->refresh(); + expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE) + ->and($sender->debt_since)->toBeNull() + ->and($sender->suspended_reason)->toBeNull() + ->and($sender->note)->toBeNull() + // Оплачен месяц ВПЕРЁД от сегодняшнего дня, а не от протухшего paid_until. + ->and($sender->paid_until->toDateString())->toBe('2026-08-25'); + + // Списан РОВНО один месяц, а не три: за месяцы, когда имя не работало, + // не берём (решение владельца В-133). + expect((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub')) + ->toBe('4900.00'); + + $tx = AdWalletTransaction::where('tenant_id', $tenant->id) + ->where('type', AdWalletTransaction::TYPE_CHARGE)->get(); + expect($tx)->toHaveCount(1) + ->and($tx->first()->external_key)->toBe("sms:sender:{$sender->id}:2026-07"); +}); + +it('4.4 — денег не хватает: имя остаётся отключённым и НИЧЕГО не списывается', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '50.00', null, 'test'); // < плата 100 + $sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT); + + app(ChargeSmsNameFeeJob::class)->handle(); + + $sender->refresh(); + expect($sender->status)->toBe(ClientSmsSender::STATUS_SUSPENDED) + ->and($sender->debt_since->toDateString())->toBe('2026-04-26') + ->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('50.00'); + + expect(AdWalletTransaction::where('tenant_id', $tenant->id) + ->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(0); +}); + +it('4.4 — замороженные под рассылку деньги свободными не считаются', function () { + $tenant = Tenant::factory()->create(); + $wallet = app(AdWalletService::class); + $wallet->topup($tenant->id, '120.00', null, 'test'); + $wallet->freeze($tenant->id, 'sms', 'campaign', 777, '100.00'); // свободно 20 < плата 100 + $sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT); + + app(ChargeSmsNameFeeJob::class)->handle(); + + expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_SUSPENDED) + ->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('120.00'); +}); + +it('4.4 — имя, отключённое РУКОЙ ВЛАДЕЛЬЦА, работник обратно НЕ включает (В-140)', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test'); // денег полно + $sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_OWNER); + + app(ChargeSmsNameFeeJob::class)->handle(); + + // Владелец гасил имя у МТС — портал не вправе включить его обратно, тем более за деньги клиента. + expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_SUSPENDED) + ->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('5000.00'); + + expect(AdWalletTransaction::where('tenant_id', $tenant->id) + ->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(0); +}); + +it('4.4 — за текущий месяц дважды не спишем: клиент уже оплатил кнопкой (В-141)', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test'); + $sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT); + + // Так спишет кнопка «включить имя обратно» (Task 4) — тот же месяц, тот же ключ. + app(AdWalletService::class)->charge( + $tenant->id, 'sms', 'sender', $sender->id, '100.00', "sms:sender:{$sender->id}:2026-07", + ); + + app(ChargeSmsNameFeeJob::class)->handle(); + + expect((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('4900.00'); + expect(AdWalletTransaction::where('tenant_id', $tenant->id) + ->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1); +}); + +it('4.4 — в песочнице отключённое за долг имя не воскресает и деньги не двигаются', function () { + config(['services.sms.sandbox' => true]); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test'); + $sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT); + + app(ChargeSmsNameFeeJob::class)->handle(); + + expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_SUSPENDED) + ->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('5000.00'); +}); + +it('4.4 — отключая за долг, работник записывает ПРИЧИНУ отключения', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '50.00', null, 'test'); + // grace 29; долг с 2026-06-25 = 30 дней назад → отключение. + $sender = makeActiveSender($tenant->id, '2026-07-24', '100.00', '2026-06-25'); + + app(ChargeSmsNameFeeJob::class)->handle(); + + expect($sender->fresh()->suspended_reason)->toBe(ClientSmsSender::SUSPENDED_BY_DEBT); +}); + it('is a no-op in sandbox mode (no money moves)', function () { config(['services.sms.sandbox' => true]); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 003660bb..139f7259 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -8,6 +8,39 @@ > параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор), > поэтому при сведении они перенумерованы. Содержание не менялось. +## v9.18 (2026-08-01) — Клиентская СМС, Этап 4: ПОЧЕМУ имя отправителя отключено (одна колонка) + +**Миграция:** `2026_08_01_100600_add_suspended_reason_to_client_sms_senders.php`. Новых таблиц не заводит — только одна колонка. + +| Таблица | Колонка | Тип | Зачем | +|---|---|---|---| +| `client_sms_senders` | `suspended_reason` | `varchar(16) NULL` | Почему имя отключено: `debt` — за долг ночным работником, `owner` — рукой владельца из админки. Пусто у всех остальных состояний | + +**Зачем вообще (журнал В-140).** Строка листа 4.4 требует, чтобы имя, отключённое **за долг**, +возвращалось само, как только у клиента появились деньги. Но отключить имя может и владелец +руками — кнопкой служебного отключения, с запиской «Отключено владельцем — погасить у МТС», и +долга она не трогает вовсе. Без этой колонки признаком «за долг» пришлось бы считать пару +«отключено + есть непогашенный долг», а под неё попадает и ручное отключение клиента, у которого +долг как раз висел. Ночной работник включил бы такое имя обратно и **списал бы за него деньги** — +за имя, которое у МТС погашено и не работает. Отличать по тексту записки нельзя: текст — не +признак (тот же класс, что В-70 «причина живёт в четырёх местах»). + +**Старые строки.** У имён, отключённых до этой миграции, графа пустая — такие сами не воскресают. +Направление безопасное: портал не берёт чужих денег по догадке, а клиент включает имя кнопкой +(строка листа 4.5). Досыпать значение задним числом намеренно не стали — ровно потому, что +отличить в старых данных долг от ручного отключения уже нечем. + +**Прав не требует.** Колонка наследует привилегии таблицы; `client_sms_senders` уже имеет +`UPDATE` у рабочей роли `crm_app_user` и у админской `crm_admin_user` — проверено сторожем +`MigrationGrantsTest`, список в нём не менялся. + +**Повторный накат.** Колонка под проверкой `Schema::hasColumn` — файл можно накатывать заново. +🔴 Оговорка та же, что у всего семейства (решение В-131): в SQL, который печатает +`migrate --pretend`, этой защиты НЕТ. Прерванный ручной накат продолжать с первого неисполненного +куска, а не начинать сначала. + +--- + ## v9.17 (2026-08-01) — Клиентская СМС, Этап 4: сторож зависших рассылок (две колонки) **Миграция:** `2026_08_01_100500_add_stuck_watchdog_to_client_sms.php`. Новых таблиц не заводит — только две колонки.