6e771636ef
Начальник отдела продаж отмечает фирмы прогрева галочками, пишет текст, видит цену ДО отправки и журнал после. Отправки СМС в проекте не было вообще — ни у СМС-центра (только баланс и 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 <noreply@anthropic.com>
244 lines
10 KiB
PHP
244 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\SalesAdAudienceFirm;
|
||
use App\Models\SalesAdAudiencePhone;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
/**
|
||
* Приём фирм в прогрев рекламой сервис-каналом «Поиск → Портал» (контракт v2).
|
||
*
|
||
* Контракт сменился: приходит фирма со снимком и списком номеров, а не голый
|
||
* номер (v1). Фирма опознаётся по ИНН — повторная отправка не плодит дубль,
|
||
* а начинает прогрев заново (начальник осознанно вернул фирму в рекламу).
|
||
*
|
||
* Токен канала — config('sales.integration_token') (middleware SalesIntegrationToken).
|
||
* В тестовом окружении env пуст, поэтому задаём его здесь, как в SalesIntegrationApiTest.
|
||
*
|
||
* SharesSupplierPdo обязателен: обе модели прибиты к соединению pgsql_supplier
|
||
* (отдельный PDO), и без шаринга DatabaseTransactions откатывал бы только
|
||
* соединение по умолчанию — записи текли бы из теста в тест.
|
||
*/
|
||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||
|
||
beforeEach(function () {
|
||
config(['sales.integration_token' => 'test-token']);
|
||
});
|
||
|
||
function warmupPayload(array $override = []): array
|
||
{
|
||
return ['firms' => [array_merge([
|
||
'firm_name' => 'Тестовая стоматология',
|
||
'firm_inn' => '7700000001',
|
||
'city' => 'Москва',
|
||
'rubric' => 'Стоматологии',
|
||
'phones' => ['79990000001', '79990000002'],
|
||
'payload' => ['site' => 'example.test'],
|
||
'contacts' => [['name' => 'Директор', 'phones' => ['79990000001']]],
|
||
], $override)]];
|
||
}
|
||
|
||
it('без токена канал закрыт', function () {
|
||
$this->postJson('/api/sales/integration/ad-audience', warmupPayload())
|
||
->assertStatus(401);
|
||
});
|
||
|
||
it('заводит фирму на прогрев вместе с её номерами', function () {
|
||
$this->postJson('/api/sales/integration/ad-audience', warmupPayload(), ['X-Sales-Token' => 'test-token'])
|
||
->assertOk()
|
||
->assertJson(['added' => 1]);
|
||
|
||
$firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail();
|
||
|
||
expect($firm->phones()->count())->toBe(2)
|
||
->and($firm->prospect_id)->toBeNull()
|
||
->and($firm->warmup_started_at)->not->toBeNull();
|
||
});
|
||
|
||
it('повторная отправка не плодит дубль, а начинает прогрев заново', function () {
|
||
$headers = ['X-Sales-Token' => 'test-token'];
|
||
|
||
$this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk();
|
||
|
||
SalesAdAudienceFirm::where('firm_inn', '7700000001')
|
||
->update(['warmup_started_at' => now()->subDays(10), 'stopped_at' => now()]);
|
||
|
||
$this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)
|
||
->assertOk()
|
||
->assertJson(['added' => 0, 'renewed' => 1]);
|
||
|
||
$firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail();
|
||
|
||
expect(SalesAdAudienceFirm::count())->toBe(1)
|
||
->and($firm->stopped_at)->toBeNull()
|
||
->and($firm->warmup_started_at->isToday())->toBeTrue();
|
||
});
|
||
|
||
it('добавляет новый номер к уже заведённой фирме', function () {
|
||
$headers = ['X-Sales-Token' => 'test-token'];
|
||
|
||
$this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk();
|
||
$this->postJson(
|
||
'/api/sales/integration/ad-audience',
|
||
warmupPayload(['phones' => ['79990000001', '79990000003']]),
|
||
$headers
|
||
)->assertOk();
|
||
|
||
expect(SalesAdAudiencePhone::count())->toBe(3);
|
||
});
|
||
|
||
it('отвергает номер не в формате Яндекса', function () {
|
||
$this->postJson(
|
||
'/api/sales/integration/ad-audience',
|
||
warmupPayload(['phones' => ['+7 (999) 000-00-01']]),
|
||
['X-Sales-Token' => 'test-token']
|
||
)->assertStatus(422);
|
||
});
|
||
|
||
it('фирму без единого номера в прогрев не берём', function () {
|
||
$this->postJson(
|
||
'/api/sales/integration/ad-audience',
|
||
warmupPayload(['phones' => []]),
|
||
['X-Sales-Token' => 'test-token']
|
||
)->assertStatus(422);
|
||
});
|
||
|
||
it('старый формат channels=both включает обе галочки ch_yandex и ch_vk', function () {
|
||
$this->postJson(
|
||
'/api/sales/integration/ad-audience',
|
||
warmupPayload(['channels' => 'both']),
|
||
['X-Sales-Token' => 'test-token']
|
||
)->assertOk();
|
||
|
||
$firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail();
|
||
|
||
expect($firm->ch_yandex)->toBeTrue()
|
||
->and($firm->ch_vk)->toBeTrue()
|
||
->and($firm->ch_mts)->toBeFalse();
|
||
});
|
||
|
||
it('без указания площадки греет в Яндексе (ch_yandex=true по умолчанию)', function () {
|
||
$payload = warmupPayload();
|
||
unset($payload['firms'][0]['channels']);
|
||
|
||
$this->postJson('/api/sales/integration/ad-audience', $payload, ['X-Sales-Token' => 'test-token'])
|
||
->assertOk();
|
||
|
||
$firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail();
|
||
|
||
expect($firm->ch_yandex)->toBeTrue()
|
||
->and($firm->ch_vk)->toBeFalse()
|
||
->and($firm->ch_mts)->toBeFalse();
|
||
});
|
||
|
||
it('новый формат ch_yandex/ch_vk/ch_mts проставляет площадки явно', function () {
|
||
$payload = warmupPayload();
|
||
unset($payload['firms'][0]['channels']);
|
||
$payload['firms'][0]['ch_yandex'] = false;
|
||
$payload['firms'][0]['ch_vk'] = true;
|
||
$payload['firms'][0]['ch_mts'] = true;
|
||
|
||
$this->postJson('/api/sales/integration/ad-audience', $payload, ['X-Sales-Token' => 'test-token'])
|
||
->assertOk();
|
||
|
||
$firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail();
|
||
|
||
expect($firm->ch_yandex)->toBeFalse()
|
||
->and($firm->ch_vk)->toBeTrue()
|
||
->and($firm->ch_mts)->toBeTrue();
|
||
});
|
||
|
||
it('отвергает выдуманную площадку', function () {
|
||
$this->postJson(
|
||
'/api/sales/integration/ad-audience',
|
||
warmupPayload(['channels' => 'telegram']),
|
||
['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);
|
||
});
|