feat(смс-клиент): имя, отключённое за долг, возвращается само — за один месяц вперёд
Строка листа 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 чисто. Фронт не трогался.
This commit is contained in:
@@ -137,6 +137,10 @@ class AdminSmsSenderController extends Controller
|
||||
$sender->update([
|
||||
'status' => ClientSmsSender::STATUS_SUSPENDED,
|
||||
'note' => 'Отключено владельцем — погасить у МТС',
|
||||
// Причина обязательна: без неё ночной работник, увидев отключённое имя
|
||||
// с непогашенным долгом, включил бы его обратно за деньги клиента и
|
||||
// отменил бы это решение (журнал В-140).
|
||||
'suspended_reason' => ClientSmsSender::SUSPENDED_BY_OWNER,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* ПОЧЕМУ имя отправителя отключено (строка листа 4.4, журнал В-140).
|
||||
*
|
||||
* Имя отключается двумя разными способами, и портал вправе вернуть только один
|
||||
* из них:
|
||||
* — `debt` — отключил ночной работник за долг. Появились деньги → возвращаем
|
||||
* сами (решение владельца В-133: списать месяц вперёд, старый долг простить).
|
||||
* — `owner` — отключил владелец руками из админки («Отключено владельцем —
|
||||
* погасить у МТС»). Имя погашено у оператора и НЕ работает; вернуть его сам
|
||||
* портал не смеет — это отменило бы решение владельца, да ещё и за деньги клиента.
|
||||
*
|
||||
* Отличать по тексту записки (`note`) нельзя: текст — не признак, он правится
|
||||
* и переводится (тот же класс ошибки, что В-70 «причина живёт в четырёх местах»).
|
||||
*
|
||||
* У строк, отключённых ДО этой миграции, графа пустая — такие имена сами не
|
||||
* воскресают. Направление безопасное: портал не берёт чужих денег по догадке,
|
||||
* а клиент включает имя кнопкой (строка 4.5).
|
||||
*
|
||||
* Прав колонка не требует — наследует привилегии таблицы. Накат повторяем
|
||||
* (сторож — MigrationRerunTest).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('client_sms_senders', 'suspended_reason')) {
|
||||
Schema::table('client_sms_senders', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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`. Новых таблиц не заводит — только две колонки.
|
||||
|
||||
Reference in New Issue
Block a user