actingAs($user, 'sales'). * * Три числа (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 * (отдельный PDO), без шаринга DatabaseTransactions откатывал бы только соединение * по умолчанию — записи текли бы из теста в тест. */ uses(DatabaseTransactions::class, SharesSupplierPdo::class); // Task 7: список фирм на прогреве и назначение менеджера — тестам нужны оба // пользователя сразу, заводим тем же приёмом, что и остальные тесты файла. beforeEach(function () { $this->head = ads_head(); $this->manager = ads_manager(); }); // ── 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') ->getJson('/api/sales/warming/yandex') ->assertForbidden(); }); test('менеджер не может тронуть настройки рекламы', function () { SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => false]); $this->actingAs(ads_manager(), 'sales') ->patchJson('/api/sales/warming/yandex', ['enabled' => true]) ->assertForbidden(); expect(SalesAdAudiencePlatform::forCode('yandex')->fresh()->enabled)->toBeFalse(); }); test('без входа экран закрыт', function () { $this->getJson('/api/sales/warming/yandex')->assertUnauthorized(); }); // ── состояние ───────────────────────────────────────────────────────────────── test('начальник видит три числа по номерам', function () { SalesAdAudiencePlatform::forCode('yandex')->update([ 'enabled' => true, 'external_id' => 777, 'last_error' => null, 'days' => 30, ]); $firm = SalesAdAudienceFirm::create(['firm_name' => 'На Яндекс', 'ch_yandex' => true]); // В рекламе сейчас: живые (не удалённые, срок не вышел) — 3 штуки. 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)]); // ждёт отправки // Просроченный и удалённый в счёт не идут. 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()]); $response = $this->actingAs(ads_head(), 'sales') ->getJson('/api/sales/warming/yandex') ->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) ->assertJsonPath('data.external_id', 777); }); test('пустая аудитория отдаёт нули, а не ошибку', function () { $this->actingAs(ads_head(), 'sales') ->getJson('/api/sales/warming/yandex') ->assertOk() ->assertJsonPath('data.in_ads', 0) ->assertJsonPath('data.expiring_week', 0) ->assertJsonPath('data.waiting_sync', 0); }); test('ошибка последней синхронизации видна начальнику', function () { SalesAdAudiencePlatform::forCode('yandex')->update(['last_error' => 'не задан YANDEX_AUDIENCE_TOKEN']); $this->actingAs(ads_head(), 'sales') ->getJson('/api/sales/warming/yandex') ->assertOk() ->assertJsonPath('data.last_error', 'не задан YANDEX_AUDIENCE_TOKEN'); }); // ── настройки ───────────────────────────────────────────────────────────────── test('начальник включает рекламу и меняет срок', function () { SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => false, 'days' => 30]); $this->actingAs(ads_head(), 'sales') ->patchJson('/api/sales/warming/yandex', ['enabled' => true, 'days' => 45]) ->assertOk() ->assertJsonPath('data.enabled', true) ->assertJsonPath('data.days', 45); expect(SalesAdAudiencePlatform::forCode('yandex')->fresh()->enabled)->toBeTrue(); expect(SalesAdAudiencePlatform::forCode('yandex')->fresh()->days)->toBe(45); }); test('можно менять только рубильник, срок остаётся прежним', function () { SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => true, 'days' => 15]); $this->actingAs(ads_head(), 'sales') ->patchJson('/api/sales/warming/yandex', ['enabled' => false]) ->assertOk() ->assertJsonPath('data.enabled', false) ->assertJsonPath('data.days', 15); }); test('бессмысленный срок отвергается', function () { SalesAdAudiencePlatform::forCode('yandex')->update(['days' => 30]); foreach ([0, -5, 400, 'много'] as $bad) { $this->actingAs(ads_head(), 'sales') ->patchJson('/api/sales/warming/yandex', ['days' => $bad]) ->assertStatus(422); } expect(SalesAdAudiencePlatform::forCode('yandex')->fresh()->days)->toBe(30); }); // ── Task 7: список прогрева и назначение менеджера ────────────────────────── it('начальник видит список фирм на прогреве', function () { $firm = SalesAdAudienceFirm::create([ 'firm_name' => 'Тестовая стоматология', 'city' => 'Москва', 'ch_yandex' => true, 'warmup_started_at' => now()->subDays(2), ]); // Task 8: state считается по строке канала firm_channels(yandex, warming). $firm->firmChannels()->create([ 'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 1, ]); SalesAdAudiencePhone::create([ 'phone' => '79990000001', 'firm_id' => $firm->id, 'expires_at' => now()->addDay(), 'state' => 'active', ]); $this->actingAs($this->head, 'sales') ->getJson('/api/sales/warming/yandex/firms') ->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') ->getJson('/api/sales/warming/yandex/firms') ->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') ->postJson("/api/sales/warming/yandex/firms/{$firm->id}/assign", [ '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') ->postJson("/api/sales/warming/yandex/firms/{$firm->id}/assign", ['sales_user_id' => $this->manager->id]) ->assertStatus(422); }); // ── Task 3 (план трёх площадок): файл МТС ─────────────────────────────────── it('отдаёт файл для МТС только по фирмам, помеченным МТС', function () { $mts = SalesAdAudienceFirm::create([ 'firm_name' => 'Для МТС', 'warmup_started_at' => now(), 'ch_mts' => true, ]); // Task 7: источник состава — строка канала firm_channels(mts, warming), а не флаг ch_mts. $mts->firmChannels()->create([ 'channel' => 'mts', 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => $mts->warmup_started_at ?? now(), 'warmed_times' => 1, ]); SalesAdAudiencePhone::create([ 'phone' => '79990000001', 'firm_id' => $mts->id, 'expires_at' => now()->addDays(30), 'state' => 'active', ]); $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', ]); $response = $this->actingAs($this->head, 'sales')->get('/api/sales/warming/mts/file'); $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 () { SalesAdAudiencePlatform::forCode('mts')->update(['warmup_days' => 3]); $firm = SalesAdAudienceFirm::create([ 'firm_name' => 'Для МТС', 'warmup_started_at' => now()->subDays(10), 'ch_mts' => true, ]); // Task 7: строка канала mts warming, заезд 10 дней назад — funnel даёт stopped. $firm->firmChannels()->create([ 'channel' => 'mts', 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => now()->subDays(10), 'warmed_times' => 1, ]); SalesAdAudiencePhone::create([ 'phone' => '79990000003', 'firm_id' => $firm->id, 'expires_at' => now()->addDays(30), 'state' => 'active', ]); $telo = $this->actingAs($this->head, 'sales') ->get('/api/sales/warming/mts/file') ->streamedContent(); expect($telo)->not->toContain('79990000003'); }); it('фирма со строкой mts warming+active — её номер в файле', function () { SalesAdAudiencePlatform::forCode('mts')->update(['warmup_days' => 3]); $firm = SalesAdAudienceFirm::create([ 'firm_name' => 'Активная по МТС', 'warmup_started_at' => now(), ]); $firm->firmChannels()->create([ 'channel' => 'mts', 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => now(), 'warmed_times' => 1, ]); SalesAdAudiencePhone::create([ 'phone' => '79990000004', 'firm_id' => $firm->id, 'expires_at' => now()->addDays(30), 'state' => 'active', ]); $telo = $this->actingAs($this->head, 'sales') ->get('/api/sales/warming/mts/file') ->streamedContent(); expect($telo)->toContain('79990000004'); }); it('фирма со строкой mts loaded — номера в файле нет, даже с ch_mts=true', function () { $firm = SalesAdAudienceFirm::create([ 'firm_name' => 'Загруженная по МТС', 'warmup_started_at' => now(), 'ch_mts' => true, ]); $firm->firmChannels()->create([ 'channel' => 'mts', 'status' => 'loaded', 'mode' => 'funnel', 'warming_started_at' => now()->subDays(20), 'warmed_times' => 1, ]); SalesAdAudiencePhone::create([ 'phone' => '79990000005', 'firm_id' => $firm->id, 'expires_at' => now()->addDays(30), 'state' => 'active', ]); $telo = $this->actingAs($this->head, 'sales') ->get('/api/sales/warming/mts/file') ->streamedContent(); expect($telo)->not->toContain('79990000005'); }); // ── Task 9: состояние канала ВК на экране начальника ──────────────────────── it('показывает начальнику состояние канала ВК', function () { SalesAdAudiencePlatform::forCode('vk')->update(['status' => 'waiting_volume']); $this->actingAs(ads_head(), 'sales') ->getJson('/api/sales/warming/vk') ->assertOk() ->assertJsonPath('data.status', 'waiting_volume'); }); // ── Task 8: тоггл «Греть здесь» и показ — на строки firm_channels ──────────── it('тоггл on заводит строку канала warming/funnel и инкрементит счётчик заездов', function () { $firm = SalesAdAudienceFirm::create([ 'firm_name' => 'Тоггл ВК', 'warmup_started_at' => now()->subDays(2), ]); $this->actingAs($this->head, 'sales') ->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => true]) ->assertOk() ->assertJsonPath('ok', true); $row = $firm->firmChannels()->where('channel', 'vk')->firstOrFail(); expect($row->status)->toBe('warming') ->and($row->mode)->toBe('funnel') ->and($row->warmed_times)->toBeGreaterThanOrEqual(1) ->and($row->warming_started_at->toDateString())->toBe($firm->warmup_started_at->toDateString()); $first = $row->warmed_times; // Повторный тоггл on — счётчик заездов растёт, строка одна. $this->actingAs($this->head, 'sales') ->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => true]) ->assertOk(); expect($firm->firmChannels()->where('channel', 'vk')->count())->toBe(1); expect($firm->firmChannels()->where('channel', 'vk')->firstOrFail()->warmed_times)->toBe($first + 1); }); it('тоггл off удаляет строку канала', function () { $firm = SalesAdAudienceFirm::create([ 'firm_name' => 'Снять с ВК', 'warmup_started_at' => now()->subDays(2), ]); $firm->firmChannels()->create([ 'channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 1, ]); $this->actingAs($this->head, 'sales') ->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => false]) ->assertOk(); expect($firm->firmChannels()->where('channel', 'vk')->count())->toBe(0); }); it('firmRow ВК: state/warming_active считаются по строке канала', function () { $firm = SalesAdAudienceFirm::create([ 'firm_name' => 'Показ по строке', 'warmup_started_at' => now()->subDay(), ]); SalesAdAudiencePhone::create([ 'phone' => '79990000077', 'firm_id' => $firm->id, 'expires_at' => now()->addDays(30), 'state' => 'active', ]); // Пока строки нет — stopped и не активна. $rowNone = $this->actingAs($this->head, 'sales') ->getJson('/api/sales/warming/vk/firms')->json('firms.0'); expect($rowNone['state'])->toBe('stopped') ->and($rowNone['warming_active'])->toBeFalse(); // Включили — active и активна. $this->actingAs($this->head, 'sales') ->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => true]) ->assertOk(); $rowOn = $this->actingAs($this->head, 'sales') ->getJson('/api/sales/warming/vk/firms')->json('firms.0'); expect($rowOn['state'])->toBe('active') ->and($rowOn['warming_active'])->toBeTrue(); // Выключили — снова stopped/не активна. $this->actingAs($this->head, 'sales') ->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => false]) ->assertOk(); $rowOff = $this->actingAs($this->head, 'sales') ->getJson('/api/sales/warming/vk/firms')->json('firms.0'); expect($rowOff['state'])->toBe('stopped') ->and($rowOff['warming_active'])->toBeFalse(); }); it('тоггл ВК не трогает строку yandex', function () { $firm = SalesAdAudienceFirm::create([ 'firm_name' => 'Независимость каналов', 'warmup_started_at' => now()->subDay(), ]); $firm->firmChannels()->create([ 'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 1, ]); $this->actingAs($this->head, 'sales') ->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => true]) ->assertOk(); // yandex-строка на месте и не изменилась; vk-строка появилась. expect($firm->firmChannels()->where('channel', 'yandex')->count())->toBe(1) ->and($firm->firmChannels()->where('channel', 'yandex')->firstOrFail()->warmed_times)->toBe(1) ->and($firm->firmChannels()->where('channel', 'vk')->count())->toBe(1); });