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> @
821 lines
37 KiB
PHP
821 lines
37 KiB
PHP
<?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);
|
||
});
|