'Начальник '.uniqid(), 'email' => 'wfhead'.uniqid().'@sales.local', 'password' => bcrypt('secret'), 'role' => 'head', 'is_active' => true, ]); } function warmingFieldsManager(string $name): SalesUser { return SalesUser::create([ 'name' => $name, 'email' => 'wfmgr'.uniqid().'@sales.local', 'password' => bcrypt('secret'), 'role' => 'manager', 'is_active' => true, ]); } it('firmRow отдаёт нишу, дату запуска, менеджера и состояние прогрева', function () { $head = warmingFieldsHead(); $manager = warmingFieldsManager('Таня Михалева'); $prospect = SalesProspect::create([ 'sales_user_id' => $manager->id, 'assigned_by' => $head->id, 'stage' => 'new', 'source' => 'search', 'firm_name' => 'Гарант', 'city' => 'Екатеринбург', ]); $firm = SalesAdAudienceFirm::create([ 'firm_inn' => '6678048177', 'firm_name' => 'Гарант', 'city' => 'Екатеринбург', 'rubric' => 'Частные стоматологии', 'warmup_started_at' => now()->subDays(4), 'ch_yandex' => true, 'ch_vk' => false, 'ch_mts' => true, 'prospect_id' => $prospect->id, 'assigned_at' => now(), ]); // Task 8: warming_active/state считаются по строке канала firm_channels(yandex, warming). // Task C1: warming_channels-бейджи тоже по строкам firm_channels, а не по ch_*. $firm->firmChannels()->create([ 'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 1, ]); $firm->firmChannels()->create([ 'channel' => 'mts', 'status' => 'loaded', 'mode' => 'funnel', 'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 0, ]); SalesAdAudiencePhone::create([ 'phone' => '79000000001', 'firm_id' => $firm->id, 'firm_inn' => '6678048177', 'state' => 'active', 'expires_at' => now()->addDays(10), 'added_at' => now(), ]); $row = $this->actingAs($head, 'sales') ->getJson('/api/sales/warming/yandex/firms') ->assertOk() ->json('firms.0'); expect($row['rubric'])->toBe('Частные стоматологии'); expect($row['warmup_started_at'])->not->toBeNull(); expect($row['manager_id'])->toBe($manager->id); expect($row['manager_name'])->toBe('Таня Михалева'); expect($row['warming_active'])->toBeTrue(); expect($row['warming_channels'])->toContain('yandex'); expect($row['warming_channels'])->toContain('mts'); expect($row['warming_channels'])->not->toContain('vk'); }); it('warming_channels строит бейджи из строк firm_channels, а не из ch_*', function () { $head = warmingFieldsHead(); $firm = SalesAdAudienceFirm::create([ 'firm_name' => 'По строкам', 'warmup_started_at' => now()->subDay(), 'ch_yandex' => false, 'ch_vk' => false, 'ch_mts' => false, ]); $firm->firmChannels()->create([ 'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => now(), 'warmed_times' => 1, ]); $firm->firmChannels()->create([ 'channel' => 'mts', 'status' => 'loaded', 'mode' => 'funnel', 'warming_started_at' => now(), 'warmed_times' => 0, ]); $row = $this->actingAs($head, 'sales') ->getJson('/api/sales/warming/yandex/firms') ->assertOk() ->json('firms.0'); expect($row['warming_channels'])->toContain('yandex') ->and($row['warming_channels'])->toContain('mts') ->and($row['warming_channels'])->not->toContain('vk'); }); it('назначает менеджера платформо-независимым маршрутом ad-audience', function () { $head = warmingFieldsHead(); $manager = warmingFieldsManager('Игорь Соколов'); $firm = SalesAdAudienceFirm::create([ 'firm_inn' => '6679177538', 'firm_name' => 'Винлайт', 'warmup_started_at' => now()->subDay(), ]); $this->actingAs($head, 'sales') ->postJson("/api/sales/ad-audience/firms/{$firm->id}/assign", ['sales_user_id' => $manager->id]) ->assertOk() ->assertJsonStructure(['prospect_id']); $firm->refresh(); expect($firm->prospect_id)->not->toBeNull(); $this->actingAs($head, 'sales') ->postJson("/api/sales/ad-audience/firms/{$firm->id}/assign", ['sales_user_id' => $manager->id]) ->assertStatus(422); }); it('ставит канал sms, если номер фирмы попал в отправленную рассылку', function () { $head = warmingFieldsHead(); $firm = SalesAdAudienceFirm::create([ 'firm_inn' => '6673240656', 'firm_name' => 'Новодент', 'warmup_started_at' => now()->subDay(), 'ch_yandex' => true, ]); // Task C1: фирма попадает в firms(yandex) только при наличии строки канала yandex. $firm->firmChannels()->create([ 'channel' => 'yandex', 'status' => 'loaded', 'mode' => 'funnel', 'warming_started_at' => now(), 'warmed_times' => 0, ]); SalesAdAudiencePhone::create([ 'phone' => '79220216061', 'firm_id' => $firm->id, 'firm_inn' => '6673240656', 'state' => 'active', 'expires_at' => now()->addDays(10), 'added_at' => now(), ]); $campaignId = DB::connection('pgsql_supplier')->table('sales_sms_campaigns')->insertGetId([ 'title' => 'т', 'body' => 'т', 'status' => 'done', 'created_by' => $head->id, 'created_at' => now(), 'updated_at' => now(), ]); DB::connection('pgsql_supplier')->table('sales_sms_messages')->insert([ 'campaign_id' => $campaignId, 'phone' => '79220216061', 'status' => 'sent', ]); $row = $this->actingAs($head, 'sales')->getJson('/api/sales/warming/yandex/firms')->json('firms.0'); expect($row['warming_channels'])->toContain('sms'); }); it('не ставит sms в песочнице (fake_sent)', function () { $head = warmingFieldsHead(); $firm = SalesAdAudienceFirm::create([ 'firm_inn' => '6660000000', 'firm_name' => 'Песочница', 'warmup_started_at' => now()->subDay(), 'ch_yandex' => true, ]); // Task C1: строка канала yandex нужна, чтобы фирма вошла в firms(yandex). $firm->firmChannels()->create([ 'channel' => 'yandex', 'status' => 'loaded', 'mode' => 'funnel', 'warming_started_at' => now(), 'warmed_times' => 0, ]); SalesAdAudiencePhone::create([ 'phone' => '79000000099', 'firm_id' => $firm->id, 'firm_inn' => '6660000000', 'state' => 'active', 'expires_at' => now()->addDays(10), 'added_at' => now(), ]); $cid = DB::connection('pgsql_supplier')->table('sales_sms_campaigns')->insertGetId([ 'title' => 'т', 'body' => 'т', 'status' => 'done', 'created_by' => $head->id, 'created_at' => now(), 'updated_at' => now(), ]); DB::connection('pgsql_supplier')->table('sales_sms_messages')->insert([ 'campaign_id' => $cid, 'phone' => '79000000099', 'status' => 'fake_sent', ]); $row = $this->actingAs($head, 'sales')->getJson('/api/sales/warming/yandex/firms')->json('firms.0'); expect($row['warming_channels'])->not->toContain('sms'); }); it('firmRow отдаёт channel_status строки канала: loaded vs warming', function () { $head = warmingFieldsHead(); // Загружена, ждёт запуска (loaded). $loaded = SalesAdAudienceFirm::create([ 'firm_name' => 'Ждёт запуска', 'warmup_started_at' => now()->subDay(), ]); $loaded->firmChannels()->create([ 'channel' => 'yandex', 'status' => 'loaded', 'mode' => 'funnel', 'warming_started_at' => now(), 'warmed_times' => 0, ]); $loadedRow = $this->actingAs($head, 'sales') ->getJson('/api/sales/warming/yandex/firms')->assertOk()->json('firms.0'); expect($loadedRow['channel_status'])->toBe('loaded'); // Другая фирма, гретая (warming). $warming = SalesAdAudienceFirm::create([ 'firm_name' => 'Греется', 'warmup_started_at' => now()->subDay(), ]); $warming->firmChannels()->create([ 'channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => now(), 'warmed_times' => 1, ]); $warmingRow = $this->actingAs($head, 'sales') ->getJson('/api/sales/warming/vk/firms')->assertOk()->json('firms.0'); expect($warmingRow['channel_status'])->toBe('warming'); }); it('состояние строки считается по срокам ЗАПРОШЕННОЙ площадки', function () { $head = warmingFieldsHead(); // Одна фирма на yandex+vk, прогрев начат 4 дня назад. // yandex warmup 5 → ещё active; vk warmup 3 → уже погасла. SalesAdAudiencePlatform::forCode('yandex')->update(['warmup_days' => 5]); SalesAdAudiencePlatform::forCode('vk')->update(['warmup_days' => 3]); $firm = SalesAdAudienceFirm::create([ 'firm_name' => 'На обеих', 'warmup_started_at' => now()->subDays(4), 'ch_yandex' => true, 'ch_vk' => true, ]); // Task 8: строки каналов — точка отсчёта заезда та же, что и warmup_started_at фирмы. foreach (['yandex', 'vk'] as $ch) { $firm->firmChannels()->create([ 'channel' => $ch, 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 1, ]); } SalesAdAudiencePhone::create([ 'phone' => '79000000055', 'firm_id' => $firm->id, 'state' => 'active', 'expires_at' => now()->addDays(10), 'added_at' => now(), ]); $yandexRow = $this->actingAs($head, 'sales')->getJson('/api/sales/warming/yandex/firms')->json('firms.0'); $vkRow = $this->actingAs($head, 'sales')->getJson('/api/sales/warming/vk/firms')->json('firms.0'); expect($yandexRow['state'])->toBe('active') ->and($yandexRow['warming_active'])->toBeTrue(); expect($vkRow['state'])->toBe('stopped') ->and($vkRow['warming_active'])->toBeFalse(); }); // Task D1 (Фаза 2 Этап D): эндпоинт /ad-audience/sms-firms — фирмы со строкой // канала sms (СМС не греется сроками, всегда status='loaded'), плюс поле // sms_sent_count (число боевых СМС status='sent' по номерам фирмы) — везде, // где отдаётся firmRow(). it('sms-firms отдаёт только фирмы со строкой канала sms', function () { $head = warmingFieldsHead(); $withSms = SalesAdAudienceFirm::create([ 'firm_inn' => '6670000001', 'firm_name' => 'Со смс', 'warmup_started_at' => now()->subDay(), ]); $withSms->firmChannels()->create([ 'channel' => 'sms', 'status' => 'loaded', 'warmed_times' => 0, ]); $withoutSms = SalesAdAudienceFirm::create([ 'firm_inn' => '6670000002', 'firm_name' => 'Без смс', 'warmup_started_at' => now()->subDay(), 'ch_yandex' => true, ]); $withoutSms->firmChannels()->create([ 'channel' => 'yandex', 'status' => 'loaded', 'warmed_times' => 0, ]); $rows = $this->actingAs($head, 'sales') ->getJson('/api/sales/ad-audience/sms-firms') ->assertOk() ->json('firms'); $ids = array_column($rows, 'id'); expect($ids)->toContain($withSms->id); expect($ids)->not->toContain($withoutSms->id); }); it('sms-firms отдаёт sms_sent_count — число боевых СМС по номеру фирмы', function () { $head = warmingFieldsHead(); $firm = SalesAdAudienceFirm::create([ 'firm_inn' => '6670000003', 'firm_name' => 'Считаем смс', 'warmup_started_at' => now()->subDay(), ]); $firm->firmChannels()->create([ 'channel' => 'sms', 'status' => 'loaded', 'warmed_times' => 0, ]); SalesAdAudiencePhone::create([ 'phone' => '79110000003', 'firm_id' => $firm->id, 'firm_inn' => '6670000003', 'state' => 'active', 'expires_at' => now()->addDays(10), 'added_at' => now(), ]); // Unique-ограничение (campaign_id, phone) — два sent через РАЗНЫЕ рассылки, // третий (fake_sent, песочница) в счёт не идёт. $campaignIds = [ DB::connection('pgsql_supplier')->table('sales_sms_campaigns')->insertGetId([ 'title' => 'т1', 'body' => 'т', 'status' => 'done', 'created_by' => $head->id, 'created_at' => now(), 'updated_at' => now(), ]), DB::connection('pgsql_supplier')->table('sales_sms_campaigns')->insertGetId([ 'title' => 'т2', 'body' => 'т', 'status' => 'done', 'created_by' => $head->id, 'created_at' => now(), 'updated_at' => now(), ]), DB::connection('pgsql_supplier')->table('sales_sms_campaigns')->insertGetId([ 'title' => 'т3', 'body' => 'т', 'status' => 'done', 'created_by' => $head->id, 'created_at' => now(), 'updated_at' => now(), ]), ]; DB::connection('pgsql_supplier')->table('sales_sms_messages')->insert([ ['campaign_id' => $campaignIds[0], 'phone' => '79110000003', 'status' => 'sent'], ['campaign_id' => $campaignIds[1], 'phone' => '79110000003', 'status' => 'sent'], ['campaign_id' => $campaignIds[2], 'phone' => '79110000003', 'status' => 'fake_sent'], ]); $row = $this->actingAs($head, 'sales') ->getJson('/api/sales/ad-audience/sms-firms') ->assertOk() ->json('firms.0'); expect($row['sms_sent_count'])->toBe(2); }); it('sms-firms доступен только начальнику', function () { $manager = warmingFieldsManager('Обычный менеджер'); $this->actingAs($manager, 'sales') ->getJson('/api/sales/ad-audience/sms-firms') ->assertStatus(403); }); it('firmRow отдаёт sms_sent_count=0, если СМС не слали', function () { $head = warmingFieldsHead(); $firm = SalesAdAudienceFirm::create([ 'firm_name' => 'Без счёта смс', 'warmup_started_at' => now()->subDay(), 'ch_yandex' => true, ]); $firm->firmChannels()->create([ 'channel' => 'yandex', 'status' => 'loaded', 'warmed_times' => 0, ]); $row = $this->actingAs($head, 'sales') ->getJson('/api/sales/warming/yandex/firms') ->assertOk() ->json('firms.0'); expect($row['sms_sent_count'])->toBe(0); });