1d8723f285
Этап 3 «Время и цена», Task 2. Строки листа 3.1, 3.2 и первая половина 3.3.
У каждой строки снимка получателей появились две вещи: часовой пояс человека и момент,
раньше которого сообщение отдавать нельзя. Считается это ОДИН раз, при создании рассылки:
снимок сильнее всего (В-39), а на 20 000 номерах пересчёт на каждом витке джоба был бы
20 000 лишних расчётов.
Три состояния строки, и их важно не путать:
— пояс известен, ждать нечего → отдаём прямо сейчас;
— пояс известен, время не пришло → ждёт своего утра;
— пояса нет → ждёт уточнения региона и НЕ уходит вовсе (В-85).
Пустой пояс больше не означает «шлём по Москве». Он означает «мы не знаем, где человек
живёт», и такому номеру СМС не уходит. Про него при этом НЕ пишется в журнал рассылки
«нет маршрута»: мы его даже не пробовали отправить, и врать про него нельзя.
Рассылка, у которой часть номеров ещё ждёт, получает статус «ждёт утра» вместо «готово»,
и заморозка денег с неё не снимается — смета считалась на всех, оставшимся деньги ещё
понадобятся. Счётчик отправленного при этом обновляется: он считается из журнала, то есть
всегда правда, и человеку нужно видеть «отправлено 340 из 900» сразу, а не завтра (В-94).
Регион сделки читается из subject_code, а НЕ из region_code: последний в бою не пишет никто,
а в dev там демо-значения, и мы бы считали половину страны Тюменью — молча (В-82).
Прогоны: СМС 191/191 (было 186, 5 новых тестов), приём лидов 17/17, phpstan 0, pint чисто.
Вырезанием проверено дважды: убрал проверку «пояс известен» — покраснел тест про номер без
региона; убрал проверку «время пришло» — покраснели три теста про окно. Живьём на локальной
базе: две сделки (Москва и Камчатка) плюс пять демо-сделок без региона → ушёл один москвич,
Камчатке проставлено ожидание до 22:00 UTC (10 утра её времени), пятеро ждут региона,
рассылка висит «ждёт утра». Стенд возвращён как был.
В-93: у 19 старых тестов покраснение было закономерным — у их номеров нет региона. Боевой код
не ослаблял: фикстурам проставил регион и зафиксировал время прогона, иначе тесты зависели бы
от часа запуска. ⚠️ Ветку нельзя выкатывать между этой задачей и Task 5: пока ДаДата не начнёт
давать регион всем номерам, рассылка по своей базе и по списку руками отправит ноль.
Запись схемы v9.13.
170 lines
8.2 KiB
PHP
170 lines
8.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Jobs\SendClientSmsCampaignJob;
|
||
use App\Models\ClientSmsCampaign;
|
||
use App\Models\ClientSmsCampaignPhone;
|
||
use App\Models\ClientSmsMessage;
|
||
use App\Models\Deal;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use App\Services\Advertising\AdWalletService;
|
||
use App\Services\ClientSms\ClientSmsPricing;
|
||
use App\Services\ClientSms\ClientSmsSnapshotReader;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Facades\Queue;
|
||
|
||
/**
|
||
* Строки листа 3.1–3.3: сообщение отдаётся оператору только в окно 10:00–20:00
|
||
* по МЕСТНОМУ времени получателя, и в одной рассылке Калининград с Камчаткой
|
||
* уходят каждый в своё утро.
|
||
*
|
||
* Почему тесты парные (урок В-54): прогон, доказывающий «не ушло», сам по себе
|
||
* ничего не доказывает — ноль мог получиться от поломки. Рядом обязан стоять
|
||
* прогон «а вот теперь ушло», где всё то же самое, кроме времени суток.
|
||
*
|
||
* Помощники с префиксом window* — имена функций в Pest ГЛОБАЛЬНЫЕ.
|
||
* Синтетические номера 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);
|
||
});
|
||
|
||
/** Момент UTC по человеческой записи. */
|
||
function windowUtc(string $iso): CarbonImmutable
|
||
{
|
||
return CarbonImmutable::parse($iso, 'UTC');
|
||
}
|
||
|
||
/** Сделка с регионом. 🪤 Регион живёт в subject_code (1..89), НЕ в region_code (В-82). */
|
||
function windowDeal(int $tenantId, string $phone, int $subjectCode): Deal
|
||
{
|
||
return Deal::withoutEvents(fn () => Deal::factory()->create([
|
||
'tenant_id' => $tenantId,
|
||
'phone' => $phone,
|
||
'phone_operator' => 'МТС',
|
||
'subject_code' => $subjectCode,
|
||
'received_at' => now()->subDay(),
|
||
]));
|
||
}
|
||
|
||
/** Заказ рассылки по сделкам. */
|
||
function windowPayload(array $extra = []): array
|
||
{
|
||
return $extra + [
|
||
'title' => 'Окно',
|
||
'source' => 'deals',
|
||
'body' => 'Здравствуйте!',
|
||
'audience_days' => 30,
|
||
];
|
||
}
|
||
|
||
function windowRunJob(int $campaignId, int $tenantId): void
|
||
{
|
||
(new SendClientSmsCampaignJob($campaignId, $tenantId))->handle(
|
||
app(ClientSmsSnapshotReader::class),
|
||
app(ClientSmsPricing::class),
|
||
app(AdWalletService::class),
|
||
);
|
||
}
|
||
|
||
it('в московский полдень камчатский номер ждёт своего утра', function () {
|
||
$this->travelTo(windowUtc('2026-08-03 09:00')); // 12:00 в Москве, 21:00 на Камчатке
|
||
|
||
windowDeal($this->tenant->id, '79995550001', 82); // Москва, UTC+3
|
||
windowDeal($this->tenant->id, '79995550002', 27); // Камчатский край, UTC+12
|
||
|
||
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
|
||
|
||
$rows = ClientSmsCampaignPhone::where('campaign_id', $id)->get()->keyBy('phone');
|
||
|
||
expect($rows['79995550001']->tz_offset_minutes)->toBe(180)
|
||
->and($rows['79995550001']->send_after)->toBeNull() // Москве — прямо сейчас
|
||
->and($rows['79995550002']->tz_offset_minutes)->toBe(720)
|
||
->and($rows['79995550002']->send_after)->not->toBeNull(); // Камчатке — её утром
|
||
|
||
// Ушёл ровно один номер, и рассылка честно висит «ждёт утра», а не «готово».
|
||
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
|
||
->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550001')
|
||
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
|
||
});
|
||
|
||
it('контрольный: камчатским утром уходит камчатский, а Москва ждёт', function () {
|
||
// Тот же расклад, что выше, но время другое: 22:00 UTC = 10:00 утра на Камчатке
|
||
// и полночь в Москве. Без этого прогона «ушёл один» ничего не доказывает (В-54).
|
||
$this->travelTo(windowUtc('2026-08-02 22:00'));
|
||
|
||
windowDeal($this->tenant->id, '79995550001', 82); // Москва
|
||
windowDeal($this->tenant->id, '79995550002', 27); // Камчатка
|
||
|
||
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
|
||
|
||
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
|
||
->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550002')
|
||
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
|
||
});
|
||
|
||
it('днём в рабочее время уходят оба — окно никого не задерживает зря', function () {
|
||
// 07:00 UTC = 10:00 в Москве и… 19:00 на Камчатке: окно открыто у обоих.
|
||
$this->travelTo(windowUtc('2026-08-03 07:00'));
|
||
|
||
windowDeal($this->tenant->id, '79995550001', 82);
|
||
windowDeal($this->tenant->id, '79995550002', 27);
|
||
|
||
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
|
||
|
||
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(2)
|
||
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE);
|
||
});
|
||
|
||
it('номер без региона не уходит вовсе и ждёт уточнения — решение владельца В-85', function () {
|
||
$this->travelTo(windowUtc('2026-08-03 09:00')); // рабочий день в Москве
|
||
|
||
// Сделка, заведённая в кабинете руками: региона у неё нет вообще.
|
||
windowDeal($this->tenant->id, '79995550003', 82)->update(['subject_code' => null]);
|
||
windowDeal($this->tenant->id, '79995550001', 82);
|
||
|
||
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
|
||
|
||
$rows = ClientSmsCampaignPhone::where('campaign_id', $id)->get()->keyBy('phone');
|
||
|
||
// Он не пропущен (причины нет) и не отправлен — он ждёт, когда узнаем регион.
|
||
expect($rows['79995550003']->tz_offset_minutes)->toBeNull()
|
||
->and($rows['79995550003']->skip_reason)->toBeNull();
|
||
|
||
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
|
||
->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550001');
|
||
|
||
// И в журнале рассылки про него НЕ написано «нет маршрута»: мы его даже не пробовали.
|
||
expect(ClientSmsMessage::where('campaign_id', $id)->where('phone', '79995550003')->exists())
|
||
->toBeFalse();
|
||
});
|
||
|
||
it('ждущий утра номер не съедает заморозку и не считается отправленным', function () {
|
||
$this->travelTo(windowUtc('2026-08-03 09:00'));
|
||
|
||
windowDeal($this->tenant->id, '79995550001', 82); // Москва — уйдёт
|
||
windowDeal($this->tenant->id, '79995550002', 27); // Камчатка — подождёт
|
||
|
||
Queue::fake(); // отправку запускаем сами, чтобы посмотреть снимок до неё
|
||
|
||
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
|
||
|
||
$campaign = ClientSmsCampaign::find($id);
|
||
expect($campaign->planned_count)->toBe(2); // смета считалась на ОБОИХ
|
||
|
||
windowRunJob($id, $this->tenant->id);
|
||
|
||
$campaign->refresh();
|
||
expect($campaign->sent_count)->toBe(1)
|
||
->and($campaign->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW)
|
||
->and($campaign->stop_reason)->toBeNull();
|
||
});
|