2026-07-19 08:46:36 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
2026-07-19 11:19:46 +03:00
|
|
|
|
use App\Models\SalesAdAudienceFirm;
|
2026-07-19 08:46:36 +03:00
|
|
|
|
use App\Models\SalesAdAudiencePhone;
|
2026-07-21 11:18:25 +03:00
|
|
|
|
use App\Models\SalesAdAudiencePlatform;
|
2026-07-19 08:46:36 +03:00
|
|
|
|
use App\Models\SalesAdAudienceState;
|
2026-07-19 11:19:46 +03:00
|
|
|
|
use App\Models\SalesProspect;
|
2026-07-19 08:46:36 +03:00
|
|
|
|
use App\Models\SalesUser;
|
|
|
|
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
|
|
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-21 11:18:25 +03:00
|
|
|
|
* Экран «Реклама на кандидатов» в кабинете начальника — по площадкам (Task 8, бэк;
|
|
|
|
|
|
* разъезд на площадки — план трёх площадок Task 4+5).
|
2026-07-19 08:46:36 +03:00
|
|
|
|
*
|
2026-07-21 11:18:25 +03:00
|
|
|
|
* GET /api/sales/warming/{platform} — состояние: три числа, настройки, метка синхронизации
|
|
|
|
|
|
* PATCH /api/sales/warming/{platform} — рубильник площадки и срок хранения номера
|
2026-07-19 08:46:36 +03:00
|
|
|
|
*
|
|
|
|
|
|
* Оба метода — ТОЛЬКО начальник (role=head), как у SalesManagersController. Менеджер → 403.
|
|
|
|
|
|
* Аутентификация: $this->actingAs($user, 'sales').
|
|
|
|
|
|
*
|
2026-07-21 11:18:25 +03:00
|
|
|
|
* Три числа (in_ads/expiring_week/waiting_sync) считаются ТОЛЬКО по номерам фирм,
|
|
|
|
|
|
* подписанных на конкретную площадку (ch_yandex/ch_vk/ch_mts на sales_ad_audience_firms) —
|
|
|
|
|
|
* поэтому в тестах на числа номера всегда привязаны к firm_id фирмы с ch_yandex=true.
|
|
|
|
|
|
*
|
|
|
|
|
|
* SharesSupplierPdo обязателен: SalesAdAudiencePhone/State/Platform прибиты к pgsql_supplier
|
2026-07-19 08:46:36 +03:00
|
|
|
|
* (отдельный PDO), без шаринга DatabaseTransactions откатывал бы только соединение
|
|
|
|
|
|
* по умолчанию — записи текли бы из теста в тест.
|
|
|
|
|
|
*/
|
|
|
|
|
|
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
|
|
|
|
|
|
2026-07-19 11:19:46 +03:00
|
|
|
|
// Task 7: список фирм на прогреве и назначение менеджера — тестам нужны оба
|
|
|
|
|
|
// пользователя сразу, заводим тем же приёмом, что и остальные тесты файла.
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
|
$this->head = ads_head();
|
|
|
|
|
|
$this->manager = ads_manager();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-19 08:46:36 +03:00
|
|
|
|
// ── helpers (уникальный префикс ads_) ────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function ads_head(): SalesUser
|
|
|
|
|
|
{
|
|
|
|
|
|
return SalesUser::create([
|
|
|
|
|
|
'name' => 'Начальник '.uniqid(),
|
|
|
|
|
|
'email' => 'adshead'.uniqid().'@sales.local',
|
|
|
|
|
|
'password' => bcrypt('secret'),
|
|
|
|
|
|
'role' => 'head',
|
|
|
|
|
|
'is_active' => true,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ads_manager(): SalesUser
|
|
|
|
|
|
{
|
|
|
|
|
|
return SalesUser::create([
|
|
|
|
|
|
'name' => 'Менеджер '.uniqid(),
|
|
|
|
|
|
'email' => 'adsmgr'.uniqid().'@sales.local',
|
|
|
|
|
|
'password' => bcrypt('secret'),
|
|
|
|
|
|
'role' => 'manager',
|
|
|
|
|
|
'is_active' => true,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Номер в аудитории: живой, просроченный или не отправленный — по аргументам. */
|
|
|
|
|
|
function ads_phone(string $phone, array $attrs = []): SalesAdAudiencePhone
|
|
|
|
|
|
{
|
|
|
|
|
|
return SalesAdAudiencePhone::create(array_merge([
|
|
|
|
|
|
'phone' => $phone,
|
|
|
|
|
|
'added_at' => now(),
|
|
|
|
|
|
'expires_at' => now()->addDays(30),
|
|
|
|
|
|
], $attrs));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── доступ ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
test('менеджер не видит экран рекламы', function () {
|
|
|
|
|
|
$this->actingAs(ads_manager(), 'sales')
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->getJson('/api/sales/warming/yandex')
|
2026-07-19 08:46:36 +03:00
|
|
|
|
->assertForbidden();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('менеджер не может тронуть настройки рекламы', function () {
|
2026-07-21 11:18:25 +03:00
|
|
|
|
SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => false]);
|
2026-07-19 08:46:36 +03:00
|
|
|
|
|
|
|
|
|
|
$this->actingAs(ads_manager(), 'sales')
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->patchJson('/api/sales/warming/yandex', ['enabled' => true])
|
2026-07-19 08:46:36 +03:00
|
|
|
|
->assertForbidden();
|
|
|
|
|
|
|
2026-07-21 11:18:25 +03:00
|
|
|
|
expect(SalesAdAudiencePlatform::forCode('yandex')->fresh()->enabled)->toBeFalse();
|
2026-07-19 08:46:36 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('без входа экран закрыт', function () {
|
2026-07-21 11:18:25 +03:00
|
|
|
|
$this->getJson('/api/sales/warming/yandex')->assertUnauthorized();
|
2026-07-19 08:46:36 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── состояние ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
test('начальник видит три числа по номерам', function () {
|
2026-07-21 11:18:25 +03:00
|
|
|
|
SalesAdAudiencePlatform::forCode('yandex')->update([
|
|
|
|
|
|
'enabled' => true, 'external_id' => 777, 'last_error' => null,
|
2026-07-19 08:46:36 +03:00
|
|
|
|
]);
|
2026-07-21 11:18:25 +03:00
|
|
|
|
SalesAdAudienceState::current()->update(['days' => 30]);
|
|
|
|
|
|
|
|
|
|
|
|
$firm = SalesAdAudienceFirm::create(['firm_name' => 'На Яндекс', 'ch_yandex' => true]);
|
2026-07-19 08:46:36 +03:00
|
|
|
|
|
|
|
|
|
|
// В рекламе сейчас: живые (не удалённые, срок не вышел) — 3 штуки.
|
2026-07-21 11:18:25 +03:00
|
|
|
|
ads_phone('79990000002', ['firm_id' => $firm->id, 'synced_at' => now(), 'expires_at' => now()->addDays(30)]);
|
|
|
|
|
|
ads_phone('79990000003', ['firm_id' => $firm->id, 'synced_at' => now(), 'expires_at' => now()->addDays(3)]); // выходит на неделе
|
|
|
|
|
|
ads_phone('79990000004', ['firm_id' => $firm->id, 'expires_at' => now()->addDays(20)]); // ждёт отправки
|
2026-07-19 08:46:36 +03:00
|
|
|
|
// Просроченный и удалённый в счёт не идут.
|
2026-07-21 11:18:25 +03:00
|
|
|
|
ads_phone('79990000005', ['firm_id' => $firm->id, 'synced_at' => now(), 'expires_at' => now()->subDay()]);
|
|
|
|
|
|
ads_phone('79990000006', ['firm_id' => $firm->id, 'synced_at' => now(), 'removed_at' => now()]);
|
2026-07-19 08:46:36 +03:00
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs(ads_head(), 'sales')
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->getJson('/api/sales/warming/yandex')
|
2026-07-19 08:46:36 +03:00
|
|
|
|
->assertOk();
|
|
|
|
|
|
|
|
|
|
|
|
$response->assertJsonPath('data.in_ads', 3)
|
|
|
|
|
|
->assertJsonPath('data.expiring_week', 1)
|
|
|
|
|
|
->assertJsonPath('data.waiting_sync', 1)
|
|
|
|
|
|
->assertJsonPath('data.enabled', true)
|
|
|
|
|
|
->assertJsonPath('data.days', 30)
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->assertJsonPath('data.external_id', 777);
|
2026-07-19 08:46:36 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('пустая аудитория отдаёт нули, а не ошибку', function () {
|
|
|
|
|
|
$this->actingAs(ads_head(), 'sales')
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->getJson('/api/sales/warming/yandex')
|
2026-07-19 08:46:36 +03:00
|
|
|
|
->assertOk()
|
|
|
|
|
|
->assertJsonPath('data.in_ads', 0)
|
|
|
|
|
|
->assertJsonPath('data.expiring_week', 0)
|
|
|
|
|
|
->assertJsonPath('data.waiting_sync', 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('ошибка последней синхронизации видна начальнику', function () {
|
2026-07-21 11:18:25 +03:00
|
|
|
|
SalesAdAudiencePlatform::forCode('yandex')->update(['last_error' => 'не задан YANDEX_AUDIENCE_TOKEN']);
|
2026-07-19 08:46:36 +03:00
|
|
|
|
|
|
|
|
|
|
$this->actingAs(ads_head(), 'sales')
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->getJson('/api/sales/warming/yandex')
|
2026-07-19 08:46:36 +03:00
|
|
|
|
->assertOk()
|
|
|
|
|
|
->assertJsonPath('data.last_error', 'не задан YANDEX_AUDIENCE_TOKEN');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── настройки ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
test('начальник включает рекламу и меняет срок', function () {
|
2026-07-21 11:18:25 +03:00
|
|
|
|
SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => false]);
|
|
|
|
|
|
SalesAdAudienceState::current()->update(['days' => 30]);
|
2026-07-19 08:46:36 +03:00
|
|
|
|
|
|
|
|
|
|
$this->actingAs(ads_head(), 'sales')
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->patchJson('/api/sales/warming/yandex', ['enabled' => true, 'days' => 45])
|
2026-07-19 08:46:36 +03:00
|
|
|
|
->assertOk()
|
|
|
|
|
|
->assertJsonPath('data.enabled', true)
|
|
|
|
|
|
->assertJsonPath('data.days', 45);
|
|
|
|
|
|
|
2026-07-21 11:18:25 +03:00
|
|
|
|
expect(SalesAdAudiencePlatform::forCode('yandex')->fresh()->enabled)->toBeTrue();
|
|
|
|
|
|
expect(SalesAdAudienceState::current()->fresh()->days)->toBe(45);
|
2026-07-19 08:46:36 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('можно менять только рубильник, срок остаётся прежним', function () {
|
2026-07-21 11:18:25 +03:00
|
|
|
|
SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => true]);
|
|
|
|
|
|
SalesAdAudienceState::current()->update(['days' => 15]);
|
2026-07-19 08:46:36 +03:00
|
|
|
|
|
|
|
|
|
|
$this->actingAs(ads_head(), 'sales')
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->patchJson('/api/sales/warming/yandex', ['enabled' => false])
|
2026-07-19 08:46:36 +03:00
|
|
|
|
->assertOk()
|
|
|
|
|
|
->assertJsonPath('data.enabled', false)
|
|
|
|
|
|
->assertJsonPath('data.days', 15);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('бессмысленный срок отвергается', function () {
|
|
|
|
|
|
SalesAdAudienceState::current()->update(['days' => 30]);
|
|
|
|
|
|
|
|
|
|
|
|
foreach ([0, -5, 400, 'много'] as $bad) {
|
|
|
|
|
|
$this->actingAs(ads_head(), 'sales')
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->patchJson('/api/sales/warming/yandex', ['days' => $bad])
|
2026-07-19 08:46:36 +03:00
|
|
|
|
->assertStatus(422);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
expect(SalesAdAudienceState::current()->fresh()->days)->toBe(30);
|
|
|
|
|
|
});
|
2026-07-19 11:19:46 +03:00
|
|
|
|
|
|
|
|
|
|
// ── Task 7: список прогрева и назначение менеджера ──────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
it('начальник видит список фирм на прогреве', function () {
|
|
|
|
|
|
$firm = SalesAdAudienceFirm::create([
|
|
|
|
|
|
'firm_name' => 'Тестовая стоматология',
|
|
|
|
|
|
'city' => 'Москва',
|
2026-07-21 11:18:25 +03:00
|
|
|
|
'ch_yandex' => true,
|
2026-07-19 11:19:46 +03:00
|
|
|
|
'warmup_started_at' => now()->subDays(2),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
SalesAdAudiencePhone::create([
|
|
|
|
|
|
'phone' => '79990000001',
|
|
|
|
|
|
'firm_id' => $firm->id,
|
|
|
|
|
|
'expires_at' => now()->addDay(),
|
|
|
|
|
|
'state' => 'active',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$this->actingAs($this->head, 'sales')
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->getJson('/api/sales/warming/yandex/firms')
|
2026-07-19 11:19:46 +03:00
|
|
|
|
->assertOk()
|
|
|
|
|
|
->assertJsonPath('firms.0.firm_name', 'Тестовая стоматология')
|
|
|
|
|
|
->assertJsonPath('firms.0.state', 'active')
|
|
|
|
|
|
->assertJsonPath('firms.0.days_in_ads', 2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('менеджер список прогрева не видит', function () {
|
|
|
|
|
|
$this->actingAs($this->manager, 'sales')
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->getJson('/api/sales/warming/yandex/firms')
|
2026-07-19 11:19:46 +03:00
|
|
|
|
->assertStatus(403);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('начальник назначает менеджера и получает карточку в воронке', function () {
|
|
|
|
|
|
$firm = SalesAdAudienceFirm::create([
|
|
|
|
|
|
'firm_name' => 'Тестовая стоматология',
|
|
|
|
|
|
'firm_inn' => '7700000001',
|
|
|
|
|
|
'city' => 'Москва',
|
|
|
|
|
|
'payload' => ['site' => 'example.test'],
|
|
|
|
|
|
'contacts' => [['name' => 'Директор', 'phones' => ['79990000001']]],
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$this->actingAs($this->head, 'sales')
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->postJson("/api/sales/warming/yandex/firms/{$firm->id}/assign", [
|
2026-07-19 11:19:46 +03:00
|
|
|
|
'sales_user_id' => $this->manager->id,
|
|
|
|
|
|
])
|
|
|
|
|
|
->assertOk();
|
|
|
|
|
|
|
|
|
|
|
|
$firm->refresh();
|
|
|
|
|
|
$prospect = SalesProspect::findOrFail($firm->prospect_id);
|
|
|
|
|
|
|
|
|
|
|
|
expect($prospect->sales_user_id)->toBe($this->manager->id)
|
|
|
|
|
|
->and($prospect->stage)->toBe('new')
|
|
|
|
|
|
->and($prospect->assigned_by)->toBe($this->head->id)
|
|
|
|
|
|
->and($prospect->contacts)->toHaveCount(1)
|
|
|
|
|
|
->and($firm->assigned_at)->not->toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('повторное назначение той же фирмы отвергается', function () {
|
|
|
|
|
|
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Тестовая стоматология', 'prospect_id' => 1]);
|
|
|
|
|
|
|
|
|
|
|
|
$this->actingAs($this->head, 'sales')
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->postJson("/api/sales/warming/yandex/firms/{$firm->id}/assign", ['sales_user_id' => $this->manager->id])
|
2026-07-19 11:19:46 +03:00
|
|
|
|
->assertStatus(422);
|
|
|
|
|
|
});
|
2026-07-19 19:43:16 +03:00
|
|
|
|
|
2026-07-21 11:18:25 +03:00
|
|
|
|
// ── Task 3 (план трёх площадок): файл МТС ───────────────────────────────────
|
2026-07-19 19:43:16 +03:00
|
|
|
|
|
2026-07-20 08:07:46 +03:00
|
|
|
|
it('отдаёт файл для МТС только по фирмам, помеченным МТС', function () {
|
|
|
|
|
|
$mts = SalesAdAudienceFirm::create([
|
|
|
|
|
|
'firm_name' => 'Для МТС', 'warmup_started_at' => now(), 'ch_mts' => true,
|
|
|
|
|
|
]);
|
|
|
|
|
|
SalesAdAudiencePhone::create([
|
|
|
|
|
|
'phone' => '79990000001', 'firm_id' => $mts->id,
|
|
|
|
|
|
'expires_at' => now()->addDays(30), 'state' => 'active',
|
2026-07-19 19:43:16 +03:00
|
|
|
|
]);
|
|
|
|
|
|
|
2026-07-20 08:07:46 +03:00
|
|
|
|
$chuzhaya = SalesAdAudienceFirm::create([
|
|
|
|
|
|
'firm_name' => 'Только Яндекс', 'warmup_started_at' => now(), 'ch_yandex' => true,
|
|
|
|
|
|
]);
|
|
|
|
|
|
SalesAdAudiencePhone::create([
|
|
|
|
|
|
'phone' => '79990000002', 'firm_id' => $chuzhaya->id,
|
|
|
|
|
|
'expires_at' => now()->addDays(30), 'state' => 'active',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-07-21 11:18:25 +03:00
|
|
|
|
$response = $this->actingAs($this->head, 'sales')->get('/api/sales/warming/mts/file');
|
2026-07-20 08:07:46 +03:00
|
|
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
|
$response->assertHeader('content-type', 'text/plain; charset=UTF-8');
|
|
|
|
|
|
|
|
|
|
|
|
$telo = $response->streamedContent();
|
|
|
|
|
|
|
|
|
|
|
|
expect($telo)->toContain('79990000001')
|
|
|
|
|
|
->and($telo)->not->toContain('79990000002');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('погашенные номера в файл для МТС не попадают', function () {
|
|
|
|
|
|
$firm = SalesAdAudienceFirm::create([
|
|
|
|
|
|
'firm_name' => 'Для МТС', 'warmup_started_at' => now(), 'ch_mts' => true,
|
|
|
|
|
|
]);
|
|
|
|
|
|
SalesAdAudiencePhone::create([
|
|
|
|
|
|
'phone' => '79990000003', 'firm_id' => $firm->id,
|
|
|
|
|
|
'expires_at' => now()->addDays(30), 'state' => 'stopped',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$telo = $this->actingAs($this->head, 'sales')
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->get('/api/sales/warming/mts/file')
|
2026-07-20 08:07:46 +03:00
|
|
|
|
->streamedContent();
|
|
|
|
|
|
|
|
|
|
|
|
expect($telo)->not->toContain('79990000003');
|
2026-07-19 19:43:16 +03:00
|
|
|
|
});
|
2026-07-19 20:28:18 +03:00
|
|
|
|
|
|
|
|
|
|
// ── Task 9: состояние канала ВК на экране начальника ────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
it('показывает начальнику состояние канала ВК', function () {
|
2026-07-21 11:18:25 +03:00
|
|
|
|
SalesAdAudiencePlatform::forCode('vk')->update(['status' => 'waiting_volume']);
|
2026-07-19 20:28:18 +03:00
|
|
|
|
|
|
|
|
|
|
$this->actingAs(ads_head(), 'sales')
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->getJson('/api/sales/warming/vk')
|
2026-07-19 20:28:18 +03:00
|
|
|
|
->assertOk()
|
2026-07-21 11:18:25 +03:00
|
|
|
|
->assertJsonPath('data.status', 'waiting_volume');
|
2026-07-19 20:28:18 +03:00
|
|
|
|
});
|