991af98c97
У МТС Маркетолога нет программного доступа к рекламе в Telegram (только SMS API) — портал отдаёт готовый текстовый файл с номерами, начальник грузит руками. channels() переписан под три независимых булевых поля вместо одной строки; firms() отдаёт три признака вместо старого 'channels'. Выгрузка берёт только активные (state=active), неудалённые (removed_at IS NULL) номера фирм с ch_mts=true — иначе в МТС накопятся погашенные клиенты и придётся платить за показы им. Task 3 плана docs/superpowers/plans/2026-07-20-tri-ploshadki-i-mts.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
325 lines
14 KiB
PHP
325 lines
14 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\SalesAdAudienceFirm;
|
||
use App\Models\SalesAdAudiencePhone;
|
||
use App\Models\SalesAdAudienceState;
|
||
use App\Models\SalesProspect;
|
||
use App\Models\SalesUser;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
/**
|
||
* Экран «Реклама на кандидатов» в кабинете начальника (Task 8, бэк).
|
||
*
|
||
* GET /api/sales/ad-audience — состояние: три числа, настройки, метка синхронизации
|
||
* PATCH /api/sales/ad-audience — рубильник и срок хранения номера
|
||
*
|
||
* Оба метода — ТОЛЬКО начальник (role=head), как у SalesManagersController. Менеджер → 403.
|
||
* Аутентификация: $this->actingAs($user, 'sales').
|
||
*
|
||
* SharesSupplierPdo обязателен: SalesAdAudiencePhone/State прибиты к 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/ad-audience')
|
||
->assertForbidden();
|
||
});
|
||
|
||
test('менеджер не может тронуть настройки рекламы', function () {
|
||
SalesAdAudienceState::current()->update(['enabled' => false, 'days' => 30]);
|
||
|
||
$this->actingAs(ads_manager(), 'sales')
|
||
->patchJson('/api/sales/ad-audience', ['enabled' => true])
|
||
->assertForbidden();
|
||
|
||
expect(SalesAdAudienceState::current()->fresh()->enabled)->toBeFalse();
|
||
});
|
||
|
||
test('без входа экран закрыт', function () {
|
||
$this->getJson('/api/sales/ad-audience')->assertUnauthorized();
|
||
});
|
||
|
||
// ── состояние ─────────────────────────────────────────────────────────────────
|
||
|
||
test('начальник видит три числа по номерам', function () {
|
||
SalesAdAudienceState::current()->update([
|
||
'enabled' => true, 'days' => 30, 'yandex_segment_id' => 777, 'last_error' => null,
|
||
]);
|
||
|
||
// В рекламе сейчас: живые (не удалённые, срок не вышел) — 3 штуки.
|
||
ads_phone('79990000002', ['synced_at' => now(), 'expires_at' => now()->addDays(30)]);
|
||
ads_phone('79990000003', ['synced_at' => now(), 'expires_at' => now()->addDays(3)]); // выходит на неделе
|
||
ads_phone('79990000004', ['expires_at' => now()->addDays(20)]); // ждёт отправки
|
||
// Просроченный и удалённый в счёт не идут.
|
||
ads_phone('79990000005', ['synced_at' => now(), 'expires_at' => now()->subDay()]);
|
||
ads_phone('79990000006', ['synced_at' => now(), 'removed_at' => now()]);
|
||
|
||
$response = $this->actingAs(ads_head(), 'sales')
|
||
->getJson('/api/sales/ad-audience')
|
||
->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.segment_id', 777);
|
||
});
|
||
|
||
test('пустая аудитория отдаёт нули, а не ошибку', function () {
|
||
$this->actingAs(ads_head(), 'sales')
|
||
->getJson('/api/sales/ad-audience')
|
||
->assertOk()
|
||
->assertJsonPath('data.in_ads', 0)
|
||
->assertJsonPath('data.expiring_week', 0)
|
||
->assertJsonPath('data.waiting_sync', 0);
|
||
});
|
||
|
||
test('ошибка последней синхронизации видна начальнику', function () {
|
||
SalesAdAudienceState::current()->update(['last_error' => 'не задан YANDEX_AUDIENCE_TOKEN']);
|
||
|
||
$this->actingAs(ads_head(), 'sales')
|
||
->getJson('/api/sales/ad-audience')
|
||
->assertOk()
|
||
->assertJsonPath('data.last_error', 'не задан YANDEX_AUDIENCE_TOKEN');
|
||
});
|
||
|
||
// ── настройки ─────────────────────────────────────────────────────────────────
|
||
|
||
test('начальник включает рекламу и меняет срок', function () {
|
||
SalesAdAudienceState::current()->update(['enabled' => false, 'days' => 30]);
|
||
|
||
$this->actingAs(ads_head(), 'sales')
|
||
->patchJson('/api/sales/ad-audience', ['enabled' => true, 'days' => 45])
|
||
->assertOk()
|
||
->assertJsonPath('data.enabled', true)
|
||
->assertJsonPath('data.days', 45);
|
||
|
||
$state = SalesAdAudienceState::current()->fresh();
|
||
expect($state->enabled)->toBeTrue()->and($state->days)->toBe(45);
|
||
});
|
||
|
||
test('можно менять только рубильник, срок остаётся прежним', function () {
|
||
SalesAdAudienceState::current()->update(['enabled' => true, 'days' => 15]);
|
||
|
||
$this->actingAs(ads_head(), 'sales')
|
||
->patchJson('/api/sales/ad-audience', ['enabled' => false])
|
||
->assertOk()
|
||
->assertJsonPath('data.enabled', false)
|
||
->assertJsonPath('data.days', 15);
|
||
});
|
||
|
||
test('бессмысленный срок отвергается', function () {
|
||
SalesAdAudienceState::current()->update(['days' => 30]);
|
||
|
||
foreach ([0, -5, 400, 'много'] as $bad) {
|
||
$this->actingAs(ads_head(), 'sales')
|
||
->patchJson('/api/sales/ad-audience', ['days' => $bad])
|
||
->assertStatus(422);
|
||
}
|
||
|
||
expect(SalesAdAudienceState::current()->fresh()->days)->toBe(30);
|
||
});
|
||
|
||
// ── Task 7: список прогрева и назначение менеджера ──────────────────────────
|
||
|
||
it('начальник видит список фирм на прогреве', function () {
|
||
$firm = SalesAdAudienceFirm::create([
|
||
'firm_name' => 'Тестовая стоматология',
|
||
'city' => 'Москва',
|
||
'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/ad-audience/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/ad-audience/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/ad-audience/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/ad-audience/firms/{$firm->id}/assign", ['sales_user_id' => $this->manager->id])
|
||
->assertStatus(422);
|
||
});
|
||
|
||
// ── Task 3 (план трёх площадок): массовая простановка площадок + файл МТС ──
|
||
|
||
it('начальник ставит площадки сразу нескольким фирмам', function () {
|
||
$first = SalesAdAudienceFirm::create(['firm_name' => 'Первая', 'warmup_started_at' => now()]);
|
||
$second = SalesAdAudienceFirm::create(['firm_name' => 'Вторая', 'warmup_started_at' => now()]);
|
||
|
||
$this->actingAs($this->head, 'sales')
|
||
->postJson('/api/sales/ad-audience/channels', [
|
||
'firm_ids' => [$first->id, $second->id],
|
||
'ch_yandex' => true,
|
||
'ch_vk' => true,
|
||
'ch_mts' => false,
|
||
])
|
||
->assertOk()
|
||
->assertJson(['updated' => 2]);
|
||
|
||
$first = $first->fresh();
|
||
|
||
expect($first->ch_yandex)->toBeTrue()
|
||
->and($first->ch_vk)->toBeTrue()
|
||
->and($first->ch_mts)->toBeFalse();
|
||
});
|
||
|
||
it('менеджеру площадки менять нельзя', function () {
|
||
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Чужая', 'warmup_started_at' => now()]);
|
||
|
||
$this->actingAs($this->manager, 'sales')
|
||
->postJson('/api/sales/ad-audience/channels', [
|
||
'firm_ids' => [$firm->id], 'ch_yandex' => true, 'ch_vk' => false, 'ch_mts' => false,
|
||
])
|
||
->assertStatus(403);
|
||
|
||
expect($firm->fresh()->ch_yandex)->toBeFalse();
|
||
});
|
||
|
||
it('отдаёт файл для МТС только по фирмам, помеченным МТС', function () {
|
||
$mts = SalesAdAudienceFirm::create([
|
||
'firm_name' => 'Для МТС', 'warmup_started_at' => now(), 'ch_mts' => true,
|
||
]);
|
||
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/ad-audience/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 () {
|
||
$firm = SalesAdAudienceFirm::create([
|
||
'firm_name' => 'Для МТС', 'warmup_started_at' => now(), 'ch_mts' => true,
|
||
]);
|
||
SalesAdAudiencePhone::create([
|
||
'phone' => '79990000003', 'firm_id' => $firm->id,
|
||
'expires_at' => now()->addDays(30), 'state' => 'stopped',
|
||
]);
|
||
|
||
$telo = $this->actingAs($this->head, 'sales')
|
||
->get('/api/sales/ad-audience/mts-file')
|
||
->streamedContent();
|
||
|
||
expect($telo)->not->toContain('79990000003');
|
||
});
|
||
|
||
// ── Task 9: состояние канала ВК на экране начальника ────────────────────────
|
||
|
||
it('показывает начальнику состояние канала ВК', function () {
|
||
SalesAdAudienceState::current()->update(['vk_status' => 'waiting_volume']);
|
||
|
||
$this->actingAs(ads_head(), 'sales')
|
||
->getJson('/api/sales/ad-audience')
|
||
->assertOk()
|
||
->assertJsonPath('data.vk_status', 'waiting_volume');
|
||
});
|