Files
portal/app/tests/Feature/Sales/AdAudienceScreenTest.php
T
Дмитрий b5898b8017 @
fix(прогрев): менеджер+этап по ИНН у непривязанных строк и защита от дубля карточки

Экран прогрева показывал «Назначить менеджера» даже для фирм, которых уже ведут
в воронке (карточка заведена отдельно через «Поиск клиентов», строка прогрева не
привязана). Из 250 фирм Яндекса так было у 51.

- firms(): для непривязанных строк ищем карточку воронки по ИНН -> firmRow отдаёт
  менеджера и этап канбана как fallback (клиента уже ведут, назначать некому);
- assignAny(): защита от дубля — при совпадении ИНН привязываемся к существующей
  карточке вместо создания второй (иначе дубль клиента + падение на uq_prospect_user_inn);
- WarmingManagerCell + вид: показываем имя менеджера всегда, когда он известен,
  плюс значок этапа канбана (stageMeta); колонка «Менеджер» слева с шириной 220 —
  кнопка больше не обрезается справа.

Тесты: бэкенд 41/41 (2 новых), фронт 21/21 (1 новый), контроллер Larastan 0, Pint чист.
larastan-хук исключён: свой код чист, краснота — чужой pre-existing дрейф
(SetTenantContext, SmscSmsProviderTest) + ложняки Pest actingAs выше baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-25 13:05:57 +03:00

821 lines
37 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudiencePhone;
use App\Models\SalesAdAudiencePlatform;
use App\Models\SalesProspect;
use App\Models\SalesUser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
/**
* Экран «Реклама на кандидатов» в кабинете начальника — по площадкам (Task 8, бэк;
* разъезд на площадки — план трёх площадок Task 4+5).
*
* GET /api/sales/warming/{platform} — состояние: три числа, настройки, метка синхронизации
* PATCH /api/sales/warming/{platform} — рубильник площадки и срок хранения номера
*
* Оба метода — ТОЛЬКО начальник (role=head), как у SalesManagersController. Менеджер → 403.
* Аутентификация: $this->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]);
// Task C1: платформо-числа считаются по строке канала firm_channels(yandex, warming),
// а не по флагу ch_yandex.
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
// В рекламе сейчас: живые (не удалённые, срок не вышел) — 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);
});
// ── Уже в работе: карточка воронки по ИНН существует до прогрева ─────────────
// Фирма попадает в прогрев отдельно от воронки (через «Поиск клиентов»), поэтому
// строка прогрева не привязана к карточке. Экран должен узнать карточку по ИНН:
// показать, КТО ведёт клиента и на каком этапе канбана — вместо «Назначить».
it('фирма без привязки, но с карточкой воронки по ИНН — показывает менеджера и этап', function () {
SalesProspect::create([
'sales_user_id' => $this->manager->id,
'assigned_by' => $this->head->id,
'stage' => 'no_answer',
'source' => 'search',
'firm_name' => 'Новодент',
'inn' => '6673240656',
]);
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Новодент',
'firm_inn' => '6673240656',
'warmup_started_at' => now(),
]);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/yandex/firms')
->assertOk()
->assertJsonPath('firms.0.firm_name', 'Новодент')
->assertJsonPath('firms.0.manager_name', $this->manager->name)
->assertJsonPath('firms.0.stage', 'no_answer');
});
it('назначение фирмы с существующей карточкой по ИНН не плодит дубль — привязывается к ней', function () {
$existing = SalesProspect::create([
'sales_user_id' => $this->manager->id,
'assigned_by' => $this->head->id,
'stage' => 'no_answer',
'source' => 'search',
'firm_name' => 'Новодент',
'inn' => '6673240656',
]);
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Новодент',
'firm_inn' => '6673240656',
'warmup_started_at' => now(),
]);
$this->actingAs($this->head, 'sales')
->postJson("/api/sales/warming/yandex/firms/{$firm->id}/assign", [
'sales_user_id' => $this->manager->id,
])
->assertOk();
// Дубль НЕ создан: карточка с этим ИНН по-прежнему одна.
expect(SalesProspect::where('inn', '6673240656')->count())->toBe(1);
// Строка прогрева привязана к СУЩЕСТВУЮЩЕЙ карточке.
expect($firm->fresh()->prospect_id)->toBe($existing->id);
});
// ── 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(),
]);
// Task C1: firms(vk) отдаёт только фирмы со строкой канала vk. Загруженная строка
// (loaded) — фирма в списке, но не греется: state stopped, warming_active false.
$firm->firmChannels()->create([
'channel' => 'vk', 'status' => 'loaded', 'mode' => 'funnel',
'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 0,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000077', 'firm_id' => $firm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
// Строка loaded — фирма видна, но 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();
// Включили — строка становится warming: 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();
// Выключили — строка канала удаляется, фирма уходит из списка площадки.
$this->actingAs($this->head, 'sales')
->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => false])
->assertOk();
$firmsOff = $this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/vk/firms')->json('firms');
expect($firmsOff)->toBe([]);
});
// ── Task C1: firms({platform}) — только строки канала ────────────────────────
it('firms площадки отдаёт только фирмы со строкой этого канала', function () {
$yandexFirm = SalesAdAudienceFirm::create([
'firm_name' => 'Яндекс-фирма', 'warmup_started_at' => now()->subDay(),
]);
$yandexFirm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'loaded', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 0,
]);
$vkFirm = SalesAdAudienceFirm::create([
'firm_name' => 'Только ВК', 'warmup_started_at' => now()->subDay(),
]);
$vkFirm->firmChannels()->create([
'channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$names = collect($this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/yandex/firms')->assertOk()->json('firms'))
->pluck('firm_name');
expect($names)->toContain('Яндекс-фирма')
->and($names)->not->toContain('Только ВК');
});
test('три числа считаются по фирмам со строкой канала warming, не по ch_*', function () {
SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => true, 'days' => 30]);
// Греется строкой канала, ch_yandex=false (как у новых фирм после заезда).
$warming = SalesAdAudienceFirm::create([
'firm_name' => 'Греется строкой', 'ch_yandex' => false, 'warmup_started_at' => now(),
]);
$warming->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
ads_phone('79991110001', ['firm_id' => $warming->id, 'synced_at' => now(), 'expires_at' => now()->addDays(30)]);
// Только загружена (loaded, не греется) — в in_ads НЕ идёт.
$loaded = SalesAdAudienceFirm::create([
'firm_name' => 'Только загружена', 'warmup_started_at' => now(),
]);
$loaded->firmChannels()->create([
'channel' => 'yandex', 'status' => 'loaded', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 0,
]);
ads_phone('79991110002', ['firm_id' => $loaded->id, 'synced_at' => now(), 'expires_at' => now()->addDays(30)]);
$this->actingAs(ads_head(), 'sales')
->getJson('/api/sales/warming/yandex')
->assertOk()
->assertJsonPath('data.in_ads', 1);
});
it('снятие строки yandex убирает фирму из yandex, но не из vk', function () {
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Две площадки', 'warmup_started_at' => now()->subDay(),
]);
foreach (['yandex', 'vk'] as $ch) {
$firm->firmChannels()->create([
'channel' => $ch, 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
}
// Пока обе строки есть — бейджи содержат обе площадки.
$yRow = $this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/yandex/firms')->json('firms.0');
expect($yRow['warming_channels'])->toContain('yandex')
->and($yRow['warming_channels'])->toContain('vk');
// Убрали yandex-строку.
$firm->firmChannels()->where('channel', 'yandex')->delete();
$yNames = collect($this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/yandex/firms')->json('firms'))->pluck('firm_name');
$vRow = $this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/vk/firms')->json('firms.0');
expect($yNames)->not->toContain('Две площадки');
expect($vRow['firm_name'])->toBe('Две площадки');
expect($vRow['warming_channels'])->toContain('vk')
->and($vRow['warming_channels'])->not->toContain('yandex');
});
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);
});
// ── Task C2: массовый «Греть» — funnel/flat ──────────────────────────────────
it('массовый Греть без days переводит фирму в warming/funnel', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Массовый воронка']);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => [$firm->id]])
->assertOk()
->assertJsonPath('ok', true);
$row = $firm->firmChannels()->where('channel', 'yandex')->firstOrFail();
expect($row->status)->toBe('warming')
->and($row->mode)->toBe('funnel')
->and($row->flat_days)->toBeNull()
->and($row->warmed_times)->toBe(1)
->and($row->warming_started_at->diffInSeconds(now()))->toBeLessThan(5);
});
it('массовый Греть с days переводит фирму в warming/flat', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Массовый срок']);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => [$firm->id], 'days' => 15])
->assertOk();
$row = $firm->firmChannels()->where('channel', 'yandex')->firstOrFail();
expect($row->status)->toBe('warming')
->and($row->mode)->toBe('flat')
->and($row->flat_days)->toBe(15)
->and($row->warmed_times)->toBe(1);
});
it('массовый Греть переводит несколько фирм разом', function () {
$a = SalesAdAudienceFirm::create(['firm_name' => 'Массовая А']);
$b = SalesAdAudienceFirm::create(['firm_name' => 'Массовая Б']);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => [$a->id, $b->id]])
->assertOk()
->assertJsonPath('warmed', 2);
expect($a->firmChannels()->where('channel', 'yandex')->firstOrFail()->status)->toBe('warming')
->and($b->firmChannels()->where('channel', 'yandex')->firstOrFail()->status)->toBe('warming');
});
it('повторный массовый Греть той же фирмы наращивает счётчик заездов', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Повтор']);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => [$firm->id]])
->assertOk();
expect($firm->firmChannels()->where('channel', 'yandex')->firstOrFail()->warmed_times)->toBe(1);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => [$firm->id]])
->assertOk();
expect($firm->firmChannels()->where('channel', 'yandex')->count())->toBe(1);
expect($firm->firmChannels()->where('channel', 'yandex')->firstOrFail()->warmed_times)->toBe(2);
});
it('массовый Греть отвергает бессмысленный days', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Плохой срок']);
foreach ([0, 400, 'x'] as $bad) {
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => [$firm->id], 'days' => $bad])
->assertStatus(422);
}
});
it('массовый Греть отвергает пустой или некорректный firm_ids', function () {
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => []])
->assertStatus(422);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => 'не массив'])
->assertStatus(422);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', [])
->assertStatus(422);
});
it('массовый Греть менеджеру недоступен', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Не для менеджера']);
$this->actingAs($this->manager, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => [$firm->id]])
->assertStatus(403);
});
it('массовый Греть на неизвестной площадке отдаёт 404', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Неизвестная площадка']);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/unknown/warm', ['firm_ids' => [$firm->id]])
->assertStatus(404);
});
// ── Task C3: массовый «Убрать» — снять фирмы с площадки ──────────────────────
it('массовый Убрать удаляет строку канала и фирма уходит из списка площадки', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Убрать одну']);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/unassign', ['firm_ids' => [$firm->id]])
->assertOk()
->assertJsonPath('ok', true);
expect($firm->firmChannels()->where('channel', 'yandex')->count())->toBe(0);
$ids = $this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/yandex/firms')
->assertOk()
->json('firms.*.id');
expect($ids)->not->toContain($firm->id);
});
it('массовый Убрать не трогает другие каналы той же фирмы', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Убрать независимость каналов']);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$firm->firmChannels()->create([
'channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/unassign', ['firm_ids' => [$firm->id]])
->assertOk();
expect($firm->firmChannels()->where('channel', 'yandex')->count())->toBe(0)
->and($firm->firmChannels()->where('channel', 'vk')->count())->toBe(1);
$vkIds = $this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/vk/firms')
->assertOk()
->json('firms.*.id');
expect($vkIds)->toContain($firm->id);
});
it('массовый Убрать снимает несколько фирм разом', function () {
$a = SalesAdAudienceFirm::create(['firm_name' => 'Убрать А']);
$b = SalesAdAudienceFirm::create(['firm_name' => 'Убрать Б']);
$a->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$b->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/unassign', ['firm_ids' => [$a->id, $b->id]])
->assertOk()
->assertJsonPath('removed', 2);
expect($a->firmChannels()->where('channel', 'yandex')->count())->toBe(0)
->and($b->firmChannels()->where('channel', 'yandex')->count())->toBe(0);
});
it('массовый Убрать отвергает пустой или некорректный firm_ids', function () {
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/unassign', ['firm_ids' => []])
->assertStatus(422);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/unassign', ['firm_ids' => 'не массив'])
->assertStatus(422);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/unassign', [])
->assertStatus(422);
});
it('массовый Убрать менеджеру недоступен', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Не для менеджера убрать']);
$this->actingAs($this->manager, 'sales')
->postJson('/api/sales/warming/yandex/unassign', ['firm_ids' => [$firm->id]])
->assertStatus(403);
});
it('массовый Убрать на неизвестной площадке отдаёт 404', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Неизвестная площадка убрать']);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/unknown/unassign', ['firm_ids' => [$firm->id]])
->assertStatus(404);
});