feat(смс-клиент): хвост «Отказ: liderra.ru/s/…» в тексте — цена считается уже с ним
Галочка «добавить возможность отказа» в рассылке, по умолчанию включена (спека §9.2). Токен свой у каждого получателя, поэтому текст собирается на каждый номер отдельно в момент отправки. Длина хвоста постоянная — число кусков и цена считаются один раз, при создании рассылки, УЖЕ с хвостом: клиент видит ту длину, за которую заплатит. Заготовка хвоста живёт в одном месте (сервис ссылок отказа), длина берётся из неё же — число в код не вписано, разойтись расчёту и отправке нечем. Предпросмотр отдаёт второе число (сколько кусков было бы без хвоста), чтобы экран мог сказать прямо: хвост перевёл текст на второй кусок. Строки приёмочного листа 1.10-1.13 (серверная часть). Защита проверена вырезанием: считать цену без хвоста — 2 красных теста, не дописывать хвост при отправке — 1. ClientSms 146/146, приём лидов 17/17, phpstan по своим файлам чисто.
This commit is contained in:
@@ -87,12 +87,18 @@ class ClientSmsController extends Controller
|
||||
$data = $this->validatePayload($request);
|
||||
|
||||
$plan = $this->buildPlan($tenantId, $data);
|
||||
$segments = $this->pricing->segments($data['body']);
|
||||
$withTail = $data['with_optout_link'] ?? true;
|
||||
$segments = $this->pricing->segmentsWithTail($data['body'], $withTail);
|
||||
$sendable = count($plan->sendable);
|
||||
$totalSms = $sendable * $segments;
|
||||
|
||||
return response()->json([
|
||||
'segments' => $segments,
|
||||
// Второе число — чтобы экран мог сказать прямо: «хвост перевёл текст на
|
||||
// второй кусок, цена вдвое» (строка листа 1.12). Без него отличить это
|
||||
// от «текст и так был длинный» невозможно.
|
||||
'segments_without_tail' => $this->pricing->segments($data['body']),
|
||||
'with_optout_link' => $withTail,
|
||||
'sendable_count' => $sendable,
|
||||
'skipped' => $this->aggregateSkipped($plan),
|
||||
'estimated_cost_rub' => $this->pricing->estimateRub($sendable, $segments),
|
||||
@@ -111,8 +117,12 @@ class ClientSmsController extends Controller
|
||||
$body = $data['body'];
|
||||
$audienceDays = $data['audience_days'] ?? null;
|
||||
|
||||
// Галочка отказа по умолчанию включена (спека §9.2): её отсутствие — риск
|
||||
// юридический, а её наличие — вопрос цены, и цену клиент видит до отправки.
|
||||
$withTail = $data['with_optout_link'] ?? true;
|
||||
|
||||
$plan = $this->buildPlan($tenantId, $data);
|
||||
$segments = $this->pricing->segments($body);
|
||||
$segments = $this->pricing->segmentsWithTail($body, $withTail);
|
||||
$sendable = count($plan->sendable);
|
||||
$totalSms = $sendable * $segments;
|
||||
$price = $this->pricing->pricePerSmsRub($totalSms);
|
||||
@@ -120,7 +130,7 @@ class ClientSmsController extends Controller
|
||||
|
||||
try {
|
||||
$campaign = DB::transaction(function () use (
|
||||
$request, $tenantId, $data, $source, $body, $audienceDays,
|
||||
$request, $tenantId, $data, $source, $body, $audienceDays, $withTail,
|
||||
$segments, $sendable, $totalSms, $price, $estimated
|
||||
): ClientSmsCampaign {
|
||||
$campaign = ClientSmsCampaign::create([
|
||||
@@ -131,6 +141,7 @@ class ClientSmsController extends Controller
|
||||
'source' => $source,
|
||||
'audience_days' => $source === ClientSmsCampaign::SOURCE_DEALS ? $audienceDays : null,
|
||||
'status' => ClientSmsCampaign::STATUS_QUEUED,
|
||||
'with_optout_link' => $withTail,
|
||||
'segments' => $segments,
|
||||
'planned_count' => $sendable,
|
||||
'total_sms' => $totalSms,
|
||||
@@ -455,6 +466,7 @@ class ClientSmsController extends Controller
|
||||
'audience_days' => 'required_if:source,deals|nullable|integer|min:1',
|
||||
'phones' => 'array',
|
||||
'phones.*' => 'string|size:11',
|
||||
'with_optout_link' => 'boolean',
|
||||
...$extra,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Services\ClientSms\ClientSmsAudienceBuilder;
|
||||
use App\Services\ClientSms\ClientSmsPlan;
|
||||
use App\Services\ClientSms\ClientSmsPricing;
|
||||
use App\Services\ClientSms\ClientSmsRecipientSelector;
|
||||
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
|
||||
use App\Services\Sms\SmsOutgoing;
|
||||
use App\Services\Sms\SmsSendException;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -62,6 +63,7 @@ class SendClientSmsCampaignJob implements ShouldQueue
|
||||
ClientSmsRecipientSelector $selector,
|
||||
ClientSmsPricing $pricing,
|
||||
AdWalletService $wallet,
|
||||
ClientSmsUnsubscribeLinkService $links,
|
||||
): void {
|
||||
$sandbox = (bool) config('services.sms.sandbox');
|
||||
|
||||
@@ -132,10 +134,21 @@ class SendClientSmsCampaignJob implements ShouldQueue
|
||||
|
||||
$provider = $row['provider'];
|
||||
|
||||
// Текст собирается НА КАЖДЫЙ НОМЕР: в хвосте личная ссылка получателя
|
||||
// (спека §9.2). Длина хвоста одинакова для всех, поэтому снимок цены и
|
||||
// число кусков, посчитанные при создании рассылки, остаются верными.
|
||||
// Токен пишется в БД — только под tenant-контекстом, иначе RLS вернула бы
|
||||
// ноль и на каждый номер плодилась бы новая ссылка.
|
||||
$body = $campaign->body;
|
||||
|
||||
if ($campaign->with_optout_link) {
|
||||
$body .= $this->tenantTx(fn () => $links->tailFor($this->tenantId, $row['phone']));
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $provider->send(new SmsOutgoing(
|
||||
phone: $row['phone'],
|
||||
body: $campaign->body,
|
||||
body: $body,
|
||||
senderName: $campaign->sender_name,
|
||||
operator: $row['operator'],
|
||||
segments: $segments,
|
||||
|
||||
@@ -40,6 +40,7 @@ class ClientSmsCampaign extends Model
|
||||
'source',
|
||||
'audience_days',
|
||||
'status',
|
||||
'with_optout_link',
|
||||
'segments',
|
||||
'planned_count',
|
||||
'sent_count',
|
||||
@@ -55,6 +56,7 @@ class ClientSmsCampaign extends Model
|
||||
return [
|
||||
'tenant_id' => 'integer',
|
||||
'audience_days' => 'integer',
|
||||
'with_optout_link' => 'boolean',
|
||||
'segments' => 'integer',
|
||||
'planned_count' => 'integer',
|
||||
'sent_count' => 'integer',
|
||||
|
||||
@@ -17,6 +17,22 @@ final class ClientSmsPricing
|
||||
return $this->calculator->segments($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сколько кусков займёт текст с учётом хвоста для отказа (строки листа 1.11–1.13).
|
||||
*
|
||||
* Текст у каждого получателя свой (в хвосте его личный токен), но длина хвоста
|
||||
* одинакова для всех — поэтому цена считается один раз, здесь. Заполнитель из
|
||||
* латинских «x» правомерен: куски считаются по числу символов, а не по алфавиту.
|
||||
*/
|
||||
public function segmentsWithTail(string $body, bool $withTail): int
|
||||
{
|
||||
if (! $withTail) {
|
||||
return $this->segments($body);
|
||||
}
|
||||
|
||||
return $this->segments($body.str_repeat('x', ClientSmsUnsubscribeLinkService::tailLength()));
|
||||
}
|
||||
|
||||
/** Цена за 1 СМС для объёма V = строка с наибольшим min_qty <= V (V не меньше 1). */
|
||||
public function pricePerSmsRub(int $volume): string
|
||||
{
|
||||
|
||||
@@ -19,6 +19,28 @@ final class ClientSmsUnsubscribeLinkService
|
||||
|
||||
private const LENGTH = 12;
|
||||
|
||||
/**
|
||||
* Заготовка хвоста для отказа (спека §9.2). Живёт ЗДЕСЬ в единственном экземпляре:
|
||||
* этой же строкой считается цена и собирается сообщение. Написать её дважды —
|
||||
* значит однажды разойтись, и тогда клиент платит за одну длину, а уходит другая.
|
||||
*
|
||||
* Домен вписан строкой намеренно, а не берётся из настроек: адрес стенда длиннее
|
||||
* боевого, и цена на стенде перестала бы совпадать с ценой в бою (В-24).
|
||||
*/
|
||||
public const TAIL_PREFIX = ' Отказ: liderra.ru/s/';
|
||||
|
||||
/** Длина хвоста в символах — постоянная, потому что токен всегда одной длины. */
|
||||
public static function tailLength(): int
|
||||
{
|
||||
return mb_strlen(self::TAIL_PREFIX) + self::LENGTH;
|
||||
}
|
||||
|
||||
/** Готовый хвост для конкретного номера — то, что реально дописывается к тексту. */
|
||||
public function tailFor(int $tenantId, string $phone): string
|
||||
{
|
||||
return self::TAIL_PREFIX.$this->tokenFor($tenantId, $phone);
|
||||
}
|
||||
|
||||
public function tokenFor(int $tenantId, string $phone): string
|
||||
{
|
||||
$existing = ClientSmsUnsubscribeLink::query()
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Строки листа 1.10–1.13: дописывать ли к тексту хвост «Отказ: liderra.ru/s/…».
|
||||
*
|
||||
* DEFAULT true осознанно (спека §9.2): юридический риск от рассылки без возможности
|
||||
* отказаться выше, чем недовольство ценой. Снять галочку клиент может, но решение
|
||||
* это его и принимается явно.
|
||||
*
|
||||
* Колонка влияет на ДЕНЬГИ: с хвостом текст длиннее, кусков может стать больше,
|
||||
* цена — выше. Поэтому флаг фиксируется в самой кампании, а не читается заново при
|
||||
* отправке: снимок цены и снимок текста должны описывать одно и то же сообщение.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('client_sms_campaigns', function (Blueprint $table) {
|
||||
$table->boolean('with_optout_link')->default(true);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('client_sms_campaigns', function (Blueprint $table) {
|
||||
$table->dropColumn('with_optout_link');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\SendClientSmsCampaignJob;
|
||||
use App\Models\ClientSmsCampaign;
|
||||
use App\Models\ClientSmsContact;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\ClientSms\ClientSmsAudienceBuilder;
|
||||
use App\Services\ClientSms\ClientSmsPricing;
|
||||
use App\Services\ClientSms\ClientSmsRecipientSelector;
|
||||
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
|
||||
use App\Services\Sms\OperatorNormalizer;
|
||||
use App\Services\Sms\SmsOutgoing;
|
||||
use App\Services\Sms\SmsProvider;
|
||||
use App\Services\Sms\SmsRouter;
|
||||
use App\Services\Sms\SmsSendResult;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Строки листа 1.10–1.13: хвост «Отказ: liderra.ru/s/…» в тексте рассылки.
|
||||
*
|
||||
* Здесь сходятся текст и деньги. Токен свой у каждого номера, поэтому текст
|
||||
* собирается на каждый номер отдельно — но длина хвоста постоянная, и цена
|
||||
* считается один раз при создании, УЖЕ с хвостом. Если расчёт и сборка разойдутся
|
||||
* хоть на символ, клиент заплатит за одну длину, а уйдёт другая — поэтому длина
|
||||
* проверяется против настоящей строки, а не против числа в коде (В-21).
|
||||
*
|
||||
* Синтетические номера 7999… — реальные НИКОГДА.
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['services.sms.sandbox' => true]);
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
/** Провайдер-соглядатай: запоминает тексты, которые реально ушли бы в линию. */
|
||||
function tailSpyProvider(): SmsProvider
|
||||
{
|
||||
return new class implements SmsProvider
|
||||
{
|
||||
/** @var array<string, string> номер => текст */
|
||||
public array $bodies = [];
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'mts';
|
||||
}
|
||||
|
||||
public function servesOperators(): array
|
||||
{
|
||||
return ['mts'];
|
||||
}
|
||||
|
||||
public function priceKopecks(string $operator): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function send(SmsOutgoing $message): SmsSendResult
|
||||
{
|
||||
$this->bodies[$message->phone] = $message->body;
|
||||
|
||||
return new SmsSendResult('MSG-TAIL-1', $message->segments, 0, CarbonImmutable::now());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Кампания «по своей базе» в очереди — как её создаёт контроллер. */
|
||||
function tailCampaign(int $tenantId, string $body, bool $withTail): ClientSmsCampaign
|
||||
{
|
||||
return ClientSmsCampaign::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'title' => 'Рассылка',
|
||||
'body' => $body,
|
||||
'sender_name' => 'liderra.ru',
|
||||
'source' => ClientSmsCampaign::SOURCE_BASE,
|
||||
'status' => ClientSmsCampaign::STATUS_QUEUED,
|
||||
'with_optout_link' => $withTail,
|
||||
'segments' => 1,
|
||||
'planned_count' => 2,
|
||||
'total_sms' => 2,
|
||||
'price_rub_per_sms' => '8.50',
|
||||
'estimated_cost_rub' => '0.00',
|
||||
'created_by' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
it('предпросмотр считает длину ВМЕСТЕ с хвостом, а не без него', function () {
|
||||
$body = str_repeat('а', 60);
|
||||
|
||||
$with = $this->postJson('/api/sms/preview', [
|
||||
'source' => 'manual', 'phones' => ['79990000001'],
|
||||
'body' => $body, 'with_optout_link' => true,
|
||||
])->assertOk()->json();
|
||||
|
||||
$without = $this->postJson('/api/sms/preview', [
|
||||
'source' => 'manual', 'phones' => ['79990000001'],
|
||||
'body' => $body, 'with_optout_link' => false,
|
||||
])->assertOk()->json();
|
||||
|
||||
expect($with['segments'])->toBeGreaterThan($without['segments'])
|
||||
->and((float) $with['estimated_cost_rub'])->toBeGreaterThan((float) $without['estimated_cost_rub']);
|
||||
});
|
||||
|
||||
it('предпросмотр отдельно говорит, сколько кусков было бы без хвоста (строка 1.12)', function () {
|
||||
$res = $this->postJson('/api/sms/preview', [
|
||||
'source' => 'manual', 'phones' => ['79990000001'],
|
||||
'body' => str_repeat('а', 60), 'with_optout_link' => true,
|
||||
])->assertOk();
|
||||
|
||||
// 60 символов — один кусок; 60 + хвост — уже два. Экран обязан сказать это прямо.
|
||||
$res->assertJsonPath('segments', 2)
|
||||
->assertJsonPath('segments_without_tail', 1);
|
||||
});
|
||||
|
||||
it('галочка отказа включена по умолчанию — поле в запросе не пришло', function () {
|
||||
$this->postJson('/api/sms/campaigns', [
|
||||
'title' => 'Тест', 'source' => 'manual',
|
||||
'phones' => ['79990000001'], 'body' => str_repeat('а', 60),
|
||||
])->assertCreated();
|
||||
|
||||
$campaign = ClientSmsCampaign::latest('id')->first();
|
||||
|
||||
expect($campaign->with_optout_link)->toBeTrue()
|
||||
->and($campaign->segments)->toBe(2); // цена зафиксирована уже с хвостом
|
||||
});
|
||||
|
||||
it('клиент снял галочку — хвоста нет, кусков меньше и цена ниже', function () {
|
||||
$body = str_repeat('а', 60);
|
||||
|
||||
$this->postJson('/api/sms/campaigns', [
|
||||
'title' => 'С хвостом', 'source' => 'manual',
|
||||
'phones' => ['79990000001'], 'body' => $body, 'with_optout_link' => true,
|
||||
])->assertCreated();
|
||||
$withTail = ClientSmsCampaign::latest('id')->first();
|
||||
|
||||
$this->postJson('/api/sms/campaigns', [
|
||||
'title' => 'Без хвоста', 'source' => 'manual',
|
||||
'phones' => ['79990000001'], 'body' => $body, 'with_optout_link' => false,
|
||||
])->assertCreated();
|
||||
$without = ClientSmsCampaign::latest('id')->first();
|
||||
|
||||
expect($without->with_optout_link)->toBeFalse()
|
||||
->and($without->segments)->toBe(1)
|
||||
->and($withTail->segments)->toBe(2)
|
||||
// Цена — не выдуманное число, а строго меньше, чем за ту же рассылку с хвостом.
|
||||
->and((float) $without->estimated_cost_rub)
|
||||
->toBeLessThan((float) $withTail->estimated_cost_rub);
|
||||
});
|
||||
|
||||
it('в линию уходит текст с личной ссылкой ИМЕННО этого номера', function () {
|
||||
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС']);
|
||||
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС']);
|
||||
|
||||
$campaign = tailCampaign($this->tenant->id, 'Здравствуйте!', true);
|
||||
$spy = tailSpyProvider();
|
||||
|
||||
(new SendClientSmsCampaignJob($campaign->id, $this->tenant->id))->handle(
|
||||
app(ClientSmsAudienceBuilder::class),
|
||||
new ClientSmsRecipientSelector(new SmsRouter([$spy]), new OperatorNormalizer),
|
||||
app(ClientSmsPricing::class),
|
||||
app(AdWalletService::class),
|
||||
app(ClientSmsUnsubscribeLinkService::class),
|
||||
);
|
||||
|
||||
$links = app(ClientSmsUnsubscribeLinkService::class);
|
||||
$first = $links->tokenFor($this->tenant->id, '79990000001');
|
||||
$second = $links->tokenFor($this->tenant->id, '79990000002');
|
||||
|
||||
expect($first)->not->toBe($second)
|
||||
->and($spy->bodies['79990000001'])->toBe('Здравствуйте! Отказ: liderra.ru/s/'.$first)
|
||||
->and($spy->bodies['79990000002'])->toBe('Здравствуйте! Отказ: liderra.ru/s/'.$second);
|
||||
});
|
||||
|
||||
it('галочка снята — в линию уходит ровно исходный текст, без хвоста', function () {
|
||||
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС']);
|
||||
|
||||
$campaign = tailCampaign($this->tenant->id, 'Здравствуйте!', false);
|
||||
$spy = tailSpyProvider();
|
||||
|
||||
(new SendClientSmsCampaignJob($campaign->id, $this->tenant->id))->handle(
|
||||
app(ClientSmsAudienceBuilder::class),
|
||||
new ClientSmsRecipientSelector(new SmsRouter([$spy]), new OperatorNormalizer),
|
||||
app(ClientSmsPricing::class),
|
||||
app(AdWalletService::class),
|
||||
app(ClientSmsUnsubscribeLinkService::class),
|
||||
);
|
||||
|
||||
expect($spy->bodies['79990000001'])->toBe('Здравствуйте!');
|
||||
});
|
||||
|
||||
it('длина хвоста в расчёте цены равна длине настоящего хвоста (В-21)', function () {
|
||||
$links = app(ClientSmsUnsubscribeLinkService::class);
|
||||
|
||||
$real = $links->tailFor($this->tenant->id, '79990000001');
|
||||
|
||||
expect(mb_strlen($real))->toBe(ClientSmsUnsubscribeLinkService::tailLength());
|
||||
});
|
||||
@@ -13,6 +13,7 @@ use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\ClientSms\ClientSmsAudienceBuilder;
|
||||
use App\Services\ClientSms\ClientSmsPricing;
|
||||
use App\Services\ClientSms\ClientSmsRecipientSelector;
|
||||
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
|
||||
use App\Services\Sms\OperatorNormalizer;
|
||||
use App\Services\Sms\Providers\MtsSmsProvider;
|
||||
use App\Services\Sms\SmsOutgoing;
|
||||
@@ -96,6 +97,7 @@ it('sandbox: отправляет через заглушку, стоп-лист
|
||||
app(ClientSmsRecipientSelector::class),
|
||||
app(ClientSmsPricing::class),
|
||||
app(AdWalletService::class),
|
||||
app(ClientSmsUnsubscribeLinkService::class),
|
||||
);
|
||||
|
||||
$campaign->refresh();
|
||||
@@ -141,6 +143,7 @@ it('реальный режим: списывает по факту, снима
|
||||
realMtsSelector(),
|
||||
app(ClientSmsPricing::class),
|
||||
$wallet,
|
||||
app(ClientSmsUnsubscribeLinkService::class),
|
||||
);
|
||||
|
||||
$campaign->refresh();
|
||||
@@ -178,6 +181,7 @@ it('идемпотентность: повторный запуск не пло
|
||||
app(ClientSmsRecipientSelector::class),
|
||||
app(ClientSmsPricing::class),
|
||||
app(AdWalletService::class),
|
||||
app(ClientSmsUnsubscribeLinkService::class),
|
||||
);
|
||||
|
||||
$run();
|
||||
@@ -217,6 +221,7 @@ it('идемпотентность реального режима: второй
|
||||
realMtsSelector(),
|
||||
app(ClientSmsPricing::class),
|
||||
$wallet,
|
||||
app(ClientSmsUnsubscribeLinkService::class),
|
||||
);
|
||||
|
||||
$run();
|
||||
@@ -280,7 +285,7 @@ it('факт списывается ПОЛНОСТЬЮ при падении п
|
||||
|
||||
try {
|
||||
(new SendClientSmsCampaignJob($campaign->id, $tenant->id))->handle(
|
||||
app(ClientSmsAudienceBuilder::class), $flakySelector, app(ClientSmsPricing::class), $wallet,
|
||||
app(ClientSmsAudienceBuilder::class), $flakySelector, app(ClientSmsPricing::class), $wallet, app(ClientSmsUnsubscribeLinkService::class),
|
||||
);
|
||||
} catch (RuntimeException) {
|
||||
// ожидаемо: заход упал посреди рассылки
|
||||
@@ -289,6 +294,7 @@ it('факт списывается ПОЛНОСТЬЮ при падении п
|
||||
// Заход 2 (ретрай): рабочий провайдер — дошлёт остаток.
|
||||
(new SendClientSmsCampaignJob($campaign->id, $tenant->id))->handle(
|
||||
app(ClientSmsAudienceBuilder::class), realMtsSelector(), app(ClientSmsPricing::class), $wallet,
|
||||
app(ClientSmsUnsubscribeLinkService::class),
|
||||
);
|
||||
|
||||
// Оба номера отправлены — и ОБА оплачены (2 × 8.50 = 17.00), а не только один.
|
||||
@@ -318,6 +324,7 @@ it('нет кампании: джоб тихо выходит без исклю
|
||||
app(ClientSmsRecipientSelector::class),
|
||||
app(ClientSmsPricing::class),
|
||||
app(AdWalletService::class),
|
||||
app(ClientSmsUnsubscribeLinkService::class),
|
||||
);
|
||||
|
||||
expect(ClientSmsMessage::count())->toBe(0);
|
||||
|
||||
@@ -8,6 +8,30 @@
|
||||
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
|
||||
> поэтому при сведении они перенумерованы. Содержание не менялось.
|
||||
|
||||
## v9.03 (2026-07-30) — Клиентская СМС, Этап 1: галочка «добавить возможность отказа» в рассылке
|
||||
|
||||
`app/database/migrations/2026_07_30_100300_add_with_optout_link_to_client_sms_campaigns.php`
|
||||
— в `client_sms_campaigns` добавлена колонка:
|
||||
|
||||
- `with_optout_link` boolean **NOT NULL DEFAULT true** — дописывать ли к тексту
|
||||
хвост ` Отказ: liderra.ru/s/{token}` (спека §9.2, строки приёмочного листа 1.10–1.13).
|
||||
|
||||
**Почему DEFAULT true.** Рассылка без возможности отказаться — юридический риск;
|
||||
он выше, чем недовольство ценой. Снять галочку клиент может, но это его явное
|
||||
решение, а не умолчание системы. Существующих рассылок на момент миграции нет
|
||||
(модуль не на проде), поэтому значение по умолчанию никого не перекрашивает.
|
||||
|
||||
**Почему колонка, а не вычисление при отправке.** Хвост удлиняет текст и может
|
||||
перевести его на второй кусок — то есть удвоить цену. Цена фиксируется снимком
|
||||
при создании рассылки (`segments`, `price_rub_per_sms`, `estimated_cost_rub`),
|
||||
и флаг обязан лежать рядом с этим снимком: снимок цены и снимок текста должны
|
||||
описывать одно и то же сообщение. Иначе изменение настройки задним числом
|
||||
рассинхронизировало бы деньги и текст.
|
||||
|
||||
**RLS не трогается** — таблица уже под `tenant_isolation`, новых прав не нужно.
|
||||
|
||||
---
|
||||
|
||||
## v9.02 (2026-07-30) — Клиентская СМС, Этап 1: короткие ссылки отказа `client_sms_unsubscribe_links` + право служебной роли на стоп-лист
|
||||
|
||||
Две миграции:
|
||||
|
||||
Reference in New Issue
Block a user