32f9d87f9c
Фаза 2 Этап A, Task 7. mtsFile() выбирает фирмы по строке firm_channels (channel=mts, status=warming) вместо флага ch_mts и решает по ней через decideForChannelRow. Фирма с mts-строкой loaded в файл не попадает, даже с ch_mts=true. Формат файла (streamDownload, имя, сортировка) без изменений. Тронут только mtsFile — toggle/firmRow/firms остаются на Task 8. Бамп phpstan-baseline (+2 к actingAs/$head TestCall-ложнякам на 2 новых теста). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
354 lines
16 KiB
PHP
354 lines
16 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]);
|
||
|
||
// В рекламе сейчас: живые (не удалённые, срок не вышел) — 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),
|
||
]);
|
||
|
||
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');
|
||
});
|