Files
portal/app/tests/Feature/Sales/SalesProspectApiTest.php
T
Дмитрий 016416502a feat(воронка продаж): корзина, КП без обязательной даты, фильтр по датам — сервер
- результат «В корзину»: одна причина, дата созвона стирается, платящего не выбросить;
- реклама на корзине встаёт СРАЗУ — явной веткой, а не случайно через unknown_stage;
- у «Выслано КП» дата созвона стала необязательной: бывает «пришлите на почту,
  если интересно — перезвоню». Врущий старый тест на 422 без даты поправлен;
- фильтр доски date_mode=todo|changed + период (добавлен вид «завтра»).
  «Надо сделать» — созвон в периоде ИЛИ просрочен, кроме отказа и корзины.
  «Менялось» — есть движение стадии ИЛИ запись разговора за период.

Прогон: 1245/1245 (отдел продаж + все юнит-тесты).
2026-08-01 12:41:24 +03:00

1209 lines
57 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\SalesAttachmentRequest;
use App\Models\SalesClientAssignment;
use App\Models\SalesProspect;
use App\Models\SalesProspectNote;
use App\Models\SalesTariff;
use App\Models\SalesUser;
use App\Models\Tenant;
use App\Services\DaData\Dto\PartyLookupResult;
use App\Services\DaData\NullPartyLookup;
use App\Services\DaData\PartyLookup;
use App\Services\Sales\SalesAttachmentService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
function pr_user(string $role = 'manager'): SalesUser
{
return SalesUser::create([
'name' => 'U '.uniqid(), 'email' => 'pr'.uniqid().'@s.local',
'password' => bcrypt('secret'), 'role' => $role, 'is_active' => true,
]);
}
// ── index: менеджер ───────────────────────────────────────────────────────────
test('менеджер видит только свои карточки, сгруппированные по стадии', function () {
$mgr = pr_user('manager');
$other = pr_user('manager');
SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new', 'firm_name' => 'МОЯ']);
SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
SalesProspect::factory()->for($other, 'salesUser')->create(['stage' => 'new', 'firm_name' => 'ЧУЖАЯ']);
$res = $this->actingAs($mgr, 'sales')->getJson('/api/sales/prospects');
$res->assertOk();
$names = collect($res->json('prospects'))->pluck('firm_name');
expect($names)->toContain('МОЯ')->not->toContain('ЧУЖАЯ');
expect($res->json('by_stage'))->toHaveKeys(['new', 'negotiation']);
});
test('неаутентифицированный GET /api/sales/prospects → 401', function () {
$this->getJson('/api/sales/prospects')->assertUnauthorized();
});
// ── index: начальник + фильтр ─────────────────────────────────────────────────
test('начальник видит карточки всех менеджеров', function () {
$head = pr_user('head');
$m1 = pr_user('manager');
$m2 = pr_user('manager');
SalesProspect::factory()->for($m1, 'salesUser')->create(['firm_name' => 'A1']);
SalesProspect::factory()->for($m2, 'salesUser')->create(['firm_name' => 'B2']);
$res = $this->actingAs($head, 'sales')->getJson('/api/sales/prospects?scope=department');
$names = collect($res->json('prospects'))->pluck('firm_name');
expect($names)->toContain('A1')->toContain('B2');
});
test('начальник фильтрует по ?manager_id', function () {
$head = pr_user('head');
$m1 = pr_user('manager');
$m2 = pr_user('manager');
SalesProspect::factory()->for($m1, 'salesUser')->create(['firm_name' => 'A1']);
SalesProspect::factory()->for($m2, 'salesUser')->create(['firm_name' => 'B2']);
$res = $this->actingAs($head, 'sales')->getJson('/api/sales/prospects?scope=department&manager_id='.$m1->id);
$names = collect($res->json('prospects'))->pluck('firm_name');
expect($names)->toContain('A1')->not->toContain('B2');
});
test('начальник получает manager_counts по всем менеджерам (не зависит от фильтра)', function () {
$head = pr_user('head');
$m1 = pr_user('manager');
$m2 = pr_user('manager');
SalesProspect::factory()->count(3)->for($m1, 'salesUser')->create();
SalesProspect::factory()->count(1)->for($m2, 'salesUser')->create();
// Даже при фильтре по m1 счётчики остаются полными по всем менеджерам.
$res = $this->actingAs($head, 'sales')->getJson('/api/sales/prospects?scope=department&manager_id='.$m1->id);
$res->assertOk();
expect($res->json('manager_counts.'.$m1->id))->toBe(3);
expect($res->json('manager_counts.'.$m2->id))->toBe(1);
});
test('менеджеру manager_counts не отдаётся (пустой объект)', function () {
$mgr = pr_user('manager');
SalesProspect::factory()->for($mgr, 'salesUser')->create();
$res = $this->actingAs($mgr, 'sales')->getJson('/api/sales/prospects');
$res->assertOk();
expect($res->json('manager_counts'))->toBe([]);
});
test('by_stage содержит обе новые колонки — иначе они пустые на доске', function () {
$mgr = pr_user('manager');
SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']);
$res = $this->actingAs($mgr, 'sales')->getJson('/api/sales/prospects');
$res->assertOk();
expect($res->json('stages'))->toBe([
'new', 'in_work', 'negotiation', 'manual_testing', 'registered', 'testing',
'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer', 'trash',
]);
expect($res->json('by_stage'))->toHaveKeys(['manual_testing', 'kp_sent', 'trash']);
});
test('менеджер не может через manager_id увидеть чужие', function () {
$m1 = pr_user('manager');
$m2 = pr_user('manager');
SalesProspect::factory()->for($m2, 'salesUser')->create(['firm_name' => 'ЧУЖАЯ']);
$res = $this->actingAs($m1, 'sales')->getJson('/api/sales/prospects?manager_id='.$m2->id);
expect(collect($res->json('prospects'))->pluck('firm_name'))->not->toContain('ЧУЖАЯ');
});
// ── update: результаты разговора ──────────────────────────────────────────────
test('переговоры: ставит stage + next_call_at; без времени → 422; чужую → 403', function () {
$mgr = pr_user('manager');
$other = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']);
$foreign = SalesProspect::factory()->for($other, 'salesUser')->create(['stage' => 'new']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'negotiation'])
->assertStatus(422);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'negotiation',
'next_call_at' => '2026-07-20T10:30:00+03:00',
])
->assertOk()
->assertJsonPath('prospect.stage', 'negotiation');
expect(SalesProspect::find($p->id)->next_call_at)->not->toBeNull();
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$foreign->id}", [
'action' => 'negotiation', 'next_call_at' => '2026-07-20T10:30:00+03:00',
])
->assertStatus(403);
});
test('недозвон: причина обязательна; со причиной → no_answer', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'no_answer'])
->assertStatus(422);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'no_answer', 'reason' => 'не берёт трубку 3 дня'])
->assertOk()->assertJsonPath('prospect.stage', 'no_answer');
expect(SalesProspect::find($p->id)->reason)->toBe('не берёт трубку 3 дня');
});
// ── ручное тестирование ───────────────────────────────────────────────────────
test('ручное тестирование на телефон: стадия, поля, нормализованный номер, дата созвона', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'manual_testing',
'test_channel' => 'phone',
'test_target' => '+7 (999) 000-11-22',
'next_call_at' => '2026-08-05T12:00:00',
])
->assertOk()
->assertJsonPath('prospect.stage', 'manual_testing')
->assertJsonPath('prospect.test_channel', 'phone')
// Номер приводится к 79XXXXXXXXX — иначе один номер ляжет в базу тремя видами.
->assertJsonPath('prospect.test_target', '79990001122');
expect(SalesProspect::find($p->id)->next_call_at)->not->toBeNull();
});
test('ручное тестирование на сайт: адрес сохраняется как есть', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'manual_testing',
'test_channel' => 'site',
'test_target' => 'primer-firma.ru/zayavka',
'next_call_at' => '2026-08-05T12:00:00',
])
->assertOk()
->assertJsonPath('prospect.test_channel', 'site')
->assertJsonPath('prospect.test_target', 'primer-firma.ru/zayavka');
});
test('ручное тестирование: без канала, без значения, без даты созвона и с мусорным номером → 422', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$body = [
'action' => 'manual_testing',
'test_channel' => 'phone',
'test_target' => '79990001122',
'next_call_at' => '2026-08-05T12:00:00',
];
foreach (['test_channel', 'test_target', 'next_call_at'] as $missing) {
$without = $body;
unset($without[$missing]);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", $without)
->assertStatus(422);
}
// Канал вне списка.
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", array_merge($body, ['test_channel' => 'голубь']))
->assertStatus(422);
// Номер, который не разобрать.
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", array_merge($body, ['test_target' => 'позвоните мне']))
->assertStatus(422);
// Ни одна из неудачных попыток стадию не сдвинула.
expect(SalesProspect::find($p->id)->stage)->toBe('negotiation');
});
test('ручное тестирование: в журнале одна запись с каналом и датой созвона', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'manual_testing',
'test_channel' => 'phone',
'test_target' => '79990001122',
'next_call_at' => '2026-08-05T12:00:00',
'summary' => 'директор попросил прислать пару лидов на пробу',
])
->assertOk();
$notes = SalesProspectNote::where('prospect_id', $p->id)->get();
expect($notes)->toHaveCount(1);
expect($notes[0]->title)->toContain('Ручное тестирование: телефон 79990001122');
expect($notes[0]->title)->toContain('05.08.2026 12:00');
expect($notes[0]->body)->toBe('директор попросил прислать пару лидов на пробу');
});
// ── выслано КП ────────────────────────────────────────────────────────────────
test('выслано КП: стадия, канал, адрес и дата созвона', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'kp_sent',
'kp_channel' => 'email',
'kp_target' => 'director@primer-firma.ru',
'next_call_at' => '2026-08-05T12:00:00',
])
->assertOk()
->assertJsonPath('prospect.stage', 'kp_sent')
->assertJsonPath('prospect.kp_channel', 'email')
->assertJsonPath('prospect.kp_target', 'director@primer-firma.ru');
expect(SalesProspect::find($p->id)->next_call_at)->not->toBeNull();
});
test('выслано КП: телеграм-ник сохраняется как есть, номер не насилуем', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'kp_sent',
'kp_channel' => 'telegram',
'kp_target' => '@primer_director',
'next_call_at' => '2026-08-05T12:00:00',
])
->assertOk()
->assertJsonPath('prospect.kp_target', '@primer_director');
});
test('выслано КП: без канала, без адреса и с чужим каналом → 422', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$body = [
'action' => 'kp_sent',
'kp_channel' => 'email',
'kp_target' => 'director@primer-firma.ru',
'next_call_at' => '2026-08-05T12:00:00',
];
// next_call_at здесь НЕ проверяется: с 01.08.2026 дата созвона у КП необязательна
// (клиент говорит «пришлите на почту, если интересно — перезвоню»).
foreach (['kp_channel', 'kp_target'] as $missing) {
$without = $body;
unset($without[$missing]);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", $without)
->assertStatus(422);
}
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", array_merge($body, ['kp_channel' => 'голубем']))
->assertStatus(422);
expect(SalesProspect::find($p->id)->stage)->toBe('negotiation');
});
test('выслано КП: дата созвона необязательна — «пришлите, если интересно перезвоню»', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'kp_sent',
'kp_channel' => 'email',
'kp_target' => 'director@primer-firma.ru',
])
->assertOk()
->assertJsonPath('prospect.stage', 'kp_sent');
// Даты нет — и в журнале нет хвоста про созвон, иначе запись врала бы.
$note = SalesProspectNote::where('prospect_id', $p->id)->first();
expect($note->body)->toContain('Выслано КП: почта director@primer-firma.ru');
expect($note->body)->not->toContain('Следующий созвон');
});
test('выслано КП: «другое» тоже требует, куда именно отправили', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'kp_sent',
'kp_channel' => 'other',
'next_call_at' => '2026-08-05T12:00:00',
])
->assertStatus(422);
});
test('выслано КП: в журнале одна запись с человеческим названием канала', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'kp_sent',
'kp_channel' => 'whatsapp',
'kp_target' => '79990001122',
'next_call_at' => '2026-08-05T12:00:00',
'summary' => 'просил КП с ценами на 500 лидов',
])
->assertOk();
$notes = SalesProspectNote::where('prospect_id', $p->id)->get();
expect($notes)->toHaveCount(1);
expect($notes[0]->title)->toContain('Выслано КП: ватсап 79990001122');
expect($notes[0]->title)->toContain('05.08.2026 12:00');
expect($notes[0]->body)->toBe('просил КП с ценами на 500 лидов');
});
// ── корзина ───────────────────────────────────────────────────────────────────
test('в корзину: стадия, причина и запись в журнале', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'trash',
'reason' => 'не наш профиль, торгуют оптом стройматериалами',
])
->assertOk()
->assertJsonPath('prospect.stage', 'trash')
->assertJsonPath('prospect.reason', 'не наш профиль, торгуют оптом стройматериалами');
$note = SalesProspectNote::where('prospect_id', $p->id)->first();
expect($note->body)->toContain('В корзину: не наш профиль');
});
test('в корзину: дата созвона стирается — карточка не всплывает в «что надо сделать»', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create([
'stage' => 'negotiation',
'next_call_at' => '2026-08-05T12:00:00',
]);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'trash', 'reason' => 'дубль'])
->assertOk();
expect(SalesProspect::find($p->id)->next_call_at)->toBeNull();
});
test('в корзину без причины → 422', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'trash'])
->assertStatus(422);
expect(SalesProspect::find($p->id)->stage)->toBe('in_work');
});
test('платящего клиента в корзину не выбросить', function () {
$mgr = pr_user('manager');
foreach (['topped_up', 'user'] as $paying) {
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => $paying]);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'trash', 'reason' => 'надоел'])
->assertStatus(422);
expect(SalesProspect::find($p->id)->stage)->toBe($paying);
}
});
test('платящему клиенту оба новых результата запрещены — стадия не откатывается', function () {
$mgr = pr_user('manager');
foreach (['topped_up', 'user'] as $paying) {
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => $paying]);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'manual_testing',
'test_channel' => 'phone',
'test_target' => '79990001122',
'next_call_at' => '2026-08-05T12:00:00',
])
->assertStatus(422);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'kp_sent',
'kp_channel' => 'email',
'kp_target' => 'director@primer-firma.ru',
'next_call_at' => '2026-08-05T12:00:00',
])
->assertStatus(422);
expect(SalesProspect::find($p->id)->stage)->toBe($paying);
}
});
// ── «Взят в работу»: авто при первом открытии + возврат ───────────────────────
test('opened: карточка из «Новые» уезжает во «Взят в работу»', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'opened'])
->assertOk()->assertJsonPath('prospect.stage', 'in_work');
});
test('opened: повторное открытие и открытие на другой стадии стадию НЕ меняют', function () {
$mgr = pr_user('manager');
$inWork = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$nego = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$inWork->id}", ['action' => 'opened'])
->assertOk()->assertJsonPath('prospect.stage', 'in_work');
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$nego->id}", ['action' => 'opened'])
->assertOk()->assertJsonPath('prospect.stage', 'negotiation');
});
test('back_to_new: из «Взят в работу» возвращает в «Новые»; из другой стадии → 422', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$nego = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'back_to_new'])
->assertOk()->assertJsonPath('prospect.stage', 'new');
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$nego->id}", ['action' => 'back_to_new'])
->assertStatus(422);
});
// ── свои кандидаты: менеджер создаёт сам ──────────────────────────────────────
test('менеджер создаёт своего кандидата: source=manager, stage=new, владелец — он сам', function () {
$mgr = pr_user('manager');
$res = $this->actingAs($mgr, 'sales')->postJson('/api/sales/prospects', [
'firm_name' => 'ООО Инициатива', 'city' => 'Омск', 'phone' => '+73812',
'site' => 'iniciativa.ru', 'inn' => '5501234560', 'notes' => 'нашёл сам',
]);
$res->assertCreated()
->assertJsonPath('prospect.source', 'manager')
->assertJsonPath('prospect.stage', 'new')
->assertJsonPath('prospect.firm_name', 'ООО Инициатива');
expect(SalesProspect::where('firm_name', 'ООО Инициатива')->first()->sales_user_id)->toBe($mgr->id);
});
test('создание без названия → 422; чужого владельца подставить нельзя', function () {
$mgr = pr_user('manager');
$other = pr_user('manager');
$this->actingAs($mgr, 'sales')->postJson('/api/sales/prospects', ['city' => 'Омск', 'inn' => '5501234560'])
->assertStatus(422);
// sales_user_id из тела игнорируется — карточка всегда создателю
$this->actingAs($mgr, 'sales')->postJson('/api/sales/prospects', [
'firm_name' => 'Чужая попытка', 'inn' => '5501234560', 'sales_user_id' => $other->id,
])->assertCreated();
expect(SalesProspect::where('firm_name', 'Чужая попытка')->first()->sales_user_id)->toBe($mgr->id);
});
test('карточки из поиска помечены source=search; начальник фильтрует ?source', function () {
$head = pr_user('head');
$mgr = pr_user('manager');
SalesProspect::factory()->for($mgr, 'salesUser')->create(['firm_name' => 'ОТ_НАЧАЛЬНИКА', 'source' => 'search']);
SalesProspect::factory()->for($mgr, 'salesUser')->create(['firm_name' => 'СВОЯ', 'source' => 'manager']);
$own = $this->actingAs($head, 'sales')->getJson('/api/sales/prospects?scope=department&source=manager');
expect(collect($own->json('prospects'))->pluck('firm_name'))
->toContain('СВОЯ')->not->toContain('ОТ_НАЧАЛЬНИКА');
$all = $this->actingAs($head, 'sales')->getJson('/api/sales/prospects?scope=department');
expect(collect($all->json('prospects'))->pluck('firm_name'))->toContain('СВОЯ')->toContain('ОТ_НАЧАЛЬНИКА');
});
// ── регистрация (Этап 3): email → tenant + привязка ───────────────────────────
test('регистрация: e-mail не найден → 422', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'registered', 'email' => 'nobody@nowhere.tld'])
->assertStatus(422);
expect(SalesProspect::find($p->id)->stage)->toBe('negotiation');
});
test('регистрация: найден+свободен → stage=registered, привязка идёт ЗАЯВКОЙ начальнику', function () {
$tariff = SalesTariff::create([
'name' => 'Т '.uniqid(), 'kind' => 'topup_step',
'params' => ['threshold' => 30000, 'reward' => 500, 'periods' => []], 'is_active' => true,
]);
$mgr = pr_user('manager');
$mgr->current_tariff_id = $tariff->id;
$mgr->save();
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$tenant = Tenant::factory()->create(['contact_email' => 'reg'.uniqid().'@example.com']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'registered', 'email' => $tenant->contact_email])
->assertOk()
->assertJsonPath('prospect.stage', 'registered');
// Карточка едет сразу — клиент правда зарегистрировался.
$fresh = SalesProspect::find($p->id);
expect($fresh->linked_tenant_id)->toBe($tenant->id);
expect($fresh->registered_email)->toBe($tenant->contact_email);
// А вот привязки НЕТ, пока начальник не одобрит заявку.
expect(SalesClientAssignment::where('tenant_id', $tenant->id)->exists())->toBeFalse();
$req = SalesAttachmentRequest::where('tenant_id', $tenant->id)->first();
expect($req)->not->toBeNull();
expect($req->sales_user_id)->toBe($mgr->id);
expect($req->status)->toBe('pending');
expect($req->login_input)->toBe($tenant->contact_email);
});
test('регистрация: начальник одобряет заявку → появляется привязка со снимком тарифа', function () {
$tariff = SalesTariff::create([
'name' => 'Т '.uniqid(), 'kind' => 'topup_step',
'params' => ['threshold' => 30000, 'reward' => 500, 'periods' => []], 'is_active' => true,
]);
$head = pr_user('head');
$mgr = pr_user('manager');
$mgr->current_tariff_id = $tariff->id;
$mgr->save();
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$tenant = Tenant::factory()->create(['contact_email' => 'reg'.uniqid().'@example.com']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'registered', 'email' => $tenant->contact_email])
->assertOk();
$req = SalesAttachmentRequest::where('tenant_id', $tenant->id)->firstOrFail();
app(SalesAttachmentService::class)->decide($head, $req->id, 'approve');
$a = SalesClientAssignment::where('tenant_id', $tenant->id)->first();
expect($a)->not->toBeNull();
expect($a->sales_user_id)->toBe($mgr->id);
expect($a->tariff_id)->toBe($tariff->id);
});
test('регистрация: клиент уже закреплён за ЭТИМ менеджером → стадия едет, дубль-заявки нет', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$tenant = Tenant::factory()->create(['contact_email' => 'reg'.uniqid().'@example.com']);
SalesClientAssignment::create([
'sales_user_id' => $mgr->id, 'tenant_id' => $tenant->id,
'tariff_id' => null, 'tariff_kind' => null, 'tariff_params' => [], 'assigned_at' => now(),
]);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'registered', 'email' => $tenant->contact_email])
->assertOk()
->assertJsonPath('prospect.stage', 'registered');
expect(SalesAttachmentRequest::where('tenant_id', $tenant->id)->count())->toBe(0);
});
test('регистрация: клиент занят ДРУГИМ менеджером → заявка-конфликт начальнику, привязка не тронута', function () {
$mgr = pr_user('manager');
$other = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$tenant = Tenant::factory()->create(['contact_email' => 'reg'.uniqid().'@example.com']);
SalesClientAssignment::create([
'sales_user_id' => $other->id, 'tenant_id' => $tenant->id,
'tariff_id' => null, 'tariff_kind' => null, 'tariff_params' => [], 'assigned_at' => now(),
]);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'registered', 'email' => $tenant->contact_email])
->assertOk();
// Чужая привязка на месте — решает начальник.
$a = SalesClientAssignment::where('tenant_id', $tenant->id)->first();
expect($a->sales_user_id)->toBe($other->id);
$req = SalesAttachmentRequest::where('tenant_id', $tenant->id)->firstOrFail();
expect($req->status)->toBe('pending');
expect($req->comment)->toContain('уже закреплён за');
});
test('отказ: причина обязательна; из user запрещён', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$userStage = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'user']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'rejected'])
->assertStatus(422);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'rejected', 'reason' => 'дорого'])
->assertOk()->assertJsonPath('prospect.stage', 'rejected');
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$userStage->id}", ['action' => 'rejected', 'reason' => 'ушёл'])
->assertStatus(422);
expect(SalesProspect::find($userStage->id)->stage)->toBe('user');
});
// ── §16: ИНН обязателен, юрлицо, контактные лица, подтяжка по ИНН ─────────────
test('создание без ИНН → 201 (обязательно только название)', function () {
$mgr = pr_user('manager');
$this->actingAs($mgr, 'sales')
->postJson('/api/sales/prospects', ['firm_name' => 'Без ИНН'])
->assertCreated()
->assertJsonPath('prospect.inn', null);
});
test('пустая строка ИНН = «не знаю», а не опечатка → 201', function () {
$mgr = pr_user('manager');
$this->actingAs($mgr, 'sales')
->postJson('/api/sales/prospects', ['firm_name' => 'Пустой ИНН', 'inn' => ' '])
->assertCreated()
->assertJsonPath('prospect.inn', null);
});
test('вторая карточка без ИНН у того же менеджера не считается дублем', function () {
$mgr = pr_user('manager');
SalesProspect::factory()->for($mgr, 'salesUser')->create(['inn' => null]);
$this->actingAs($mgr, 'sales')
->postJson('/api/sales/prospects', ['firm_name' => 'Тоже без ИНН'])
->assertCreated();
});
test('ИНН с битой контрольной суммой → 422 (ловим опечатку до ДаData)', function () {
$mgr = pr_user('manager');
$this->actingAs($mgr, 'sales')
->postJson('/api/sales/prospects', ['firm_name' => 'Опечатка', 'inn' => '5501234567'])
->assertStatus(422)
->assertJsonValidationErrors('inn');
});
test('повтор ИНН у того же менеджера → понятный 422, а не 500 от БД', function () {
$mgr = pr_user('manager');
SalesProspect::factory()->for($mgr, 'salesUser')->create(['inn' => '5501234560']);
$res = $this->actingAs($mgr, 'sales')
->postJson('/api/sales/prospects', ['firm_name' => 'Дубль', 'inn' => '5501234560']);
$res->assertStatus(422)->assertJsonValidationErrors('inn');
expect(SalesProspect::where('firm_name', 'Дубль')->count())->toBe(0);
});
test('тот же ИНН у ДРУГОГО менеджера — можно (воронки независимы)', function () {
$mgr = pr_user('manager');
$other = pr_user('manager');
SalesProspect::factory()->for($other, 'salesUser')->create(['inn' => '5501234560']);
$this->actingAs($mgr, 'sales')
->postJson('/api/sales/prospects', ['firm_name' => 'Не дубль', 'inn' => '5501234560'])
->assertCreated();
});
test('юрлицо и контактные лица сохраняются; phone = первый телефон первого контакта', function () {
$mgr = pr_user('manager');
$res = $this->actingAs($mgr, 'sales')->postJson('/api/sales/prospects', [
'firm_name' => 'Стоматология «Улыбка»',
'legal_name' => 'ООО «Улыбка Плюс»',
'inn' => '5501234560',
'contacts' => [
['name' => 'Иванов Иван', 'position' => 'директор', 'phones' => ['+79130000001', '+73812000002']],
['name' => 'Петрова Анна', 'position' => 'администратор', 'phones' => ['+79130000003']],
],
]);
$res->assertCreated()
->assertJsonPath('prospect.legal_name', 'ООО «Улыбка Плюс»')
->assertJsonPath('prospect.contacts.0.name', 'Иванов Иван')
->assertJsonPath('prospect.contacts.0.position', 'директор')
->assertJsonPath('prospect.contacts.1.phones.0', '79130000003')
->assertJsonPath('prospect.phone', '79130000001');
});
test('контакт без имени и пустые телефоны отбрасываются, карточка создаётся', function () {
$mgr = pr_user('manager');
$res = $this->actingAs($mgr, 'sales')->postJson('/api/sales/prospects', [
'firm_name' => 'Полупустые контакты',
'inn' => '5501234560',
'contacts' => [
['name' => ' ', 'position' => '', 'phones' => ['', ' ']],
['name' => 'Сидоров', 'position' => null, 'phones' => ['+79130000009', '']],
],
]);
$res->assertCreated();
$contacts = $res->json('prospect.contacts');
expect($contacts)->toHaveCount(1);
expect($contacts[0]['name'])->toBe('Сидоров');
expect($contacts[0]['phones'])->toBe(['79130000009']);
});
test('руками введённые телефоны контакта чистятся: формат 79…, дубли схлопываются, неизвестное не теряется', function () {
$mgr = pr_user('manager');
$res = $this->actingAs($mgr, 'sales')->postJson('/api/sales/prospects', [
'firm_name' => 'Чистка телефонов',
'inn' => '5501234560',
'contacts' => [
['name' => 'Захаров', 'position' => null, 'phones' => ['9135191264', '89135191264', '12345']],
],
]);
$res->assertCreated();
expect($res->json('prospect.contacts.0.phones'))->toBe(['79135191264', '12345']);
});
test('подтяжка по ИНН: нашли → юрлицо и город из ДаData', function () {
$mgr = pr_user('manager');
$this->app->bind(PartyLookup::class, fn () => new class implements PartyLookup
{
public function findByInn(string $inn): ?PartyLookupResult
{
if ($inn !== '5501234560') {
return null;
}
return new PartyLookupResult(
legalName: 'ООО «Улыбка Плюс»',
kpp: null,
ogrn: null,
address: 'г Омск, ул Ленина, д 1',
type: 'LEGAL',
raw: ['data' => ['address' => ['data' => ['city' => 'Омск']]]],
);
}
});
$this->actingAs($mgr, 'sales')
->postJson('/api/sales/prospects/lookup-inn', ['inn' => '5501234560'])
->assertOk()
->assertJsonPath('found', true)
->assertJsonPath('legal_name', 'ООО «Улыбка Плюс»')
->assertJsonPath('city', 'Омск');
});
test('подтяжка по ИНН: ДаData молчит → found=false, без ошибки', function () {
$mgr = pr_user('manager');
$this->app->bind(PartyLookup::class, fn () => new NullPartyLookup);
$this->actingAs($mgr, 'sales')
->postJson('/api/sales/prospects/lookup-inn', ['inn' => '5501234560'])
->assertOk()
->assertJsonPath('found', false);
});
test('подтяжка по кривому ИНН → 422, в ДаData не идём', function () {
$mgr = pr_user('manager');
$this->actingAs($mgr, 'sales')
->postJson('/api/sales/prospects/lookup-inn', ['inn' => '123'])
->assertStatus(422);
});
// ── §18: менеджер правит контактные лица в уже заведённой карточке ────────────
test('менеджер сохраняет контактные лица в своей карточке', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work', 'contacts' => []]);
$res = $this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}/contacts", [
'contacts' => [
['name' => 'Захаров Александр', 'position' => 'ген. директор', 'phones' => ['+79136518088']],
['name' => 'Марина', 'position' => 'маркетолог', 'phones' => ['+79130000777', '+73812255739']],
],
]);
$res->assertOk()
->assertJsonPath('prospect.contacts.0.name', 'Захаров Александр')
->assertJsonPath('prospect.contacts.1.position', 'маркетолог')
->assertJsonPath('prospect.contacts.1.phones.1', '73812255739');
expect(SalesProspect::find($p->id)->contacts)->toHaveCount(2);
});
test('правка контактов: пустые люди и телефоны отсекаются, стадия не меняется', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$res = $this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}/contacts", [
'contacts' => [
['name' => ' ', 'position' => '', 'phones' => ['', ' ']],
['name' => 'Сидоров', 'position' => null, 'phones' => ['+79130000009', '']],
],
]);
$res->assertOk()->assertJsonPath('prospect.stage', 'negotiation');
$fresh = SalesProspect::find($p->id);
expect($fresh->contacts)->toHaveCount(1);
expect($fresh->contacts[0]['phones'])->toBe(['79130000009']);
});
test('правка контактов: пустой список стирает всех (менеджер ошибся и убрал)', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create([
'contacts' => [['name' => 'Кто-то', 'position' => null, 'phones' => ['+79130000001']]],
]);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}/contacts", ['contacts' => []])
->assertOk();
expect(SalesProspect::find($p->id)->contacts)->toBe([]);
});
test('правка контактов в ЧУЖОЙ карточке → 403; начальник может в любой', function () {
$mgr = pr_user('manager');
$other = pr_user('manager');
$head = pr_user('head');
$foreign = SalesProspect::factory()->for($other, 'salesUser')->create();
$body = ['contacts' => [['name' => 'Пётр', 'position' => null, 'phones' => ['+79130000002']]]];
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$foreign->id}/contacts", $body)
->assertStatus(403);
$this->actingAs($head, 'sales')
->patchJson("/api/sales/prospects/{$foreign->id}/contacts", $body)
->assertOk();
});
test('правка контактов НЕ трогает главный телефон карточки из поиска', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['phone' => '+73812244222']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}/contacts", [
'contacts' => [['name' => 'Захаров', 'position' => null, 'phones' => ['+79136518088']]],
])
->assertOk()
->assertJsonPath('prospect.phone', '+73812244222');
});
// ── §19: журнал разговоров (append-only) ──────────────────────────────────────
test('менеджер пишет краткое содержание разговора — запись ложится в журнал', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$res = $this->actingAs($mgr, 'sales')->postJson("/api/sales/prospects/{$p->id}/notes", [
'body' => 'Дозвонился, попал на администратора. Директор будет после обеда.',
]);
$res->assertCreated()
->assertJsonPath('note.kind', 'note')
->assertJsonPath('note.body', 'Дозвонился, попал на администратора. Директор будет после обеда.')
->assertJsonPath('note.author', $mgr->name);
expect(SalesProspectNote::where('prospect_id', $p->id)->count())->toBe(1);
});
test('журнал отдаётся свежими сверху', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create();
SalesProspectNote::create([
'prospect_id' => $p->id, 'sales_user_id' => $mgr->id, 'kind' => 'note', 'body' => 'СТАРАЯ',
]);
$this->travel(1)->minutes();
SalesProspectNote::create([
'prospect_id' => $p->id, 'sales_user_id' => $mgr->id, 'kind' => 'note', 'body' => 'СВЕЖАЯ',
]);
$res = $this->actingAs($mgr, 'sales')->getJson("/api/sales/prospects/{$p->id}/notes");
$res->assertOk();
expect(collect($res->json('notes'))->pluck('body')->all())->toBe(['СВЕЖАЯ', 'СТАРАЯ']);
});
test('пустое содержание не принимаем', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create();
$this->actingAs($mgr, 'sales')
->postJson("/api/sales/prospects/{$p->id}/notes", ['body' => ' '])
->assertStatus(422);
});
test('журнал чужой карточки закрыт, начальнику открыт', function () {
$mgr = pr_user('manager');
$other = pr_user('manager');
$head = pr_user('head');
$foreign = SalesProspect::factory()->for($other, 'salesUser')->create();
$this->actingAs($mgr, 'sales')->getJson("/api/sales/prospects/{$foreign->id}/notes")->assertStatus(403);
$this->actingAs($mgr, 'sales')
->postJson("/api/sales/prospects/{$foreign->id}/notes", ['body' => 'влезаю'])->assertStatus(403);
$this->actingAs($head, 'sales')->getJson("/api/sales/prospects/{$foreign->id}/notes")->assertOk();
});
test('смена стадии сама пишется в журнал: созвон, недозвон, отказ', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'negotiation', 'next_call_at' => '2026-07-20T10:30:00+03:00',
])->assertOk();
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'no_answer', 'reason' => 'не берёт трубку третий день',
])->assertOk();
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'rejected', 'reason' => 'работают с другим подрядчиком',
])->assertOk();
$log = SalesProspectNote::where('prospect_id', $p->id)->orderBy('id')->get();
expect($log)->toHaveCount(3);
expect($log[0]->kind)->toBe('stage');
expect($log[0]->body)->toContain('созвон');
expect($log[1]->body)->toContain('не берёт трубку третий день');
expect($log[2]->body)->toContain('работают с другим подрядчиком');
expect($log[2]->sales_user_id)->toBe($mgr->id);
});
test('открытие карточки и возврат в «Новые» тоже протоколируются', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']);
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'opened'])->assertOk();
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'back_to_new'])->assertOk();
$bodies = SalesProspectNote::where('prospect_id', $p->id)->orderBy('id')->pluck('body');
expect($bodies)->toHaveCount(2);
expect($bodies[0])->toContain('Взял в работу');
expect($bodies[1])->toContain('Новые');
});
test('повторное открытие карточки журнал НЕ засоряет', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'opened'])->assertOk();
expect(SalesProspectNote::where('prospect_id', $p->id)->count())->toBe(0);
});
test('PATCH больше не затирает заметку карточки мимо журнала', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create([
'stage' => 'in_work', 'notes' => 'СТАРАЯ ЗАМЕТКА',
]);
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'no_answer', 'reason' => 'занято', 'notes' => 'ПОПЫТКА ЗАТЕРЕТЬ',
])->assertOk();
// Единственный вход для текста разговора — журнал; старое значение цело.
expect(SalesProspect::find($p->id)->notes)->toBe('СТАРАЯ ЗАМЕТКА');
});
// ── §20: содержание разговора вместе с результатом + дата перезвона ───────────
test('созвон: результат и содержание — одна запись (заголовок + текст)', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'negotiation',
'next_call_at' => '2026-07-20T10:30:00+03:00',
'summary' => 'Директор на месте, просил перезвонить в понедельник с ценами.',
])->assertOk();
$log = SalesProspectNote::where('prospect_id', $p->id)->get();
expect($log)->toHaveCount(1);
expect($log[0]->kind)->toBe('note');
expect($log[0]->title)->toContain('созвон');
expect($log[0]->body)->toBe('Директор на месте, просил перезвонить в понедельник с ценами.');
});
test('содержание разговора необязательно — без него только автозапись', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'negotiation', 'next_call_at' => '2026-07-20T10:30:00+03:00', 'summary' => ' ',
])->assertOk();
expect(SalesProspectNote::where('prospect_id', $p->id)->count())->toBe(1);
});
test('недозвон: можно поставить дату следующего перезвона', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'no_answer',
'reason' => 'не берёт трубку',
'next_call_at' => '2026-07-21T09:00:00+03:00',
'summary' => 'Три гудка и сброс, попробую утром.',
])->assertOk()->assertJsonPath('prospect.stage', 'no_answer');
$fresh = SalesProspect::find($p->id);
expect($fresh->next_call_at)->not->toBeNull();
expect($fresh->next_call_at->format('Y-m-d'))->toBe('2026-07-21');
$log = SalesProspectNote::where('prospect_id', $p->id)->get();
expect($log)->toHaveCount(1);
expect($log[0]->body)->toBe('Три гудка и сброс, попробую утром.');
expect($log[0]->title)->toContain('Перезвонить 21.07');
});
test('недозвон без даты перезвона по-прежнему проходит', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'no_answer', 'reason' => 'занято',
])->assertOk();
expect(SalesProspect::find($p->id)->next_call_at)->toBeNull();
});
test('содержание разговора пишется и при отказе, и при регистрации', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'rejected', 'reason' => 'есть подрядчик', 'summary' => 'Сказал, что доволен текущим.',
])->assertOk();
expect(SalesProspectNote::where('prospect_id', $p->id)->orderByDesc('id')->first()->body)
->toBe('Сказал, что доволен текущим.');
});
// ── §20.2: результат и содержание — ОДНОЙ записью ─────────────────────────────
test('созвон с содержанием — ОДНА запись: заголовок сверху, текст снизу', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'negotiation',
'next_call_at' => '2026-07-20T17:35:00+03:00',
'summary' => 'Сказал наберёт меня маркетолог',
])->assertOk();
$log = SalesProspectNote::where('prospect_id', $p->id)->get();
expect($log)->toHaveCount(1);
expect($log[0]->kind)->toBe('note');
expect($log[0]->title)->toContain('Договорились на созвон 20.07.2026 17:35');
expect($log[0]->body)->toBe('Сказал наберёт меня маркетолог');
});
test('результат без содержания — по-прежнему одна запись «этап» без заголовка', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'no_answer', 'reason' => 'занято',
])->assertOk();
$log = SalesProspectNote::where('prospect_id', $p->id)->get();
expect($log)->toHaveCount(1);
expect($log[0]->kind)->toBe('stage');
expect($log[0]->title)->toBeNull();
expect($log[0]->body)->toContain('Не дозвонился: занято');
});
test('ручная запись журнала заголовка не имеет', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create();
$res = $this->actingAs($mgr, 'sales')
->postJson("/api/sales/prospects/{$p->id}/notes", ['body' => 'просто заметка']);
$res->assertCreated()->assertJsonPath('note.title', null);
});
test('журнал отдаёт заголовок вместе с записью', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'rejected', 'reason' => 'дорого', 'summary' => 'Сравнивал с конкурентом',
])->assertOk();
$res = $this->actingAs($mgr, 'sales')->getJson("/api/sales/prospects/{$p->id}/notes");
$res->assertOk()
->assertJsonPath('notes.0.title', 'Отказ: дорого')
->assertJsonPath('notes.0.body', 'Сравнивал с конкурентом');
});
// ── строгое разделение ролей начальника (18.07.2026) ──────────────────────────
test('начальник на СВОЁМ экране кандидатов видит только свои карточки', function () {
$head = pr_user('head');
$mgr = pr_user('manager');
SalesProspect::factory()->for($head, 'salesUser')->create(['firm_name' => 'МОЯ_КАРТОЧКА']);
SalesProspect::factory()->for($mgr, 'salesUser')->create(['firm_name' => 'КАРТОЧКА_МЕНЕДЖЕРА']);
// Без scope — экран «Потенциальные клиенты» (раздел МЕНЕДЖЕР): только личное.
$mine = $this->actingAs($head, 'sales')->getJson('/api/sales/prospects');
expect(collect($mine->json('prospects'))->pluck('firm_name'))
->toContain('МОЯ_КАРТОЧКА')->not->toContain('КАРТОЧКА_МЕНЕДЖЕРА');
// scope=department — экран «Воронка отдела» (раздел НАЧАЛЬНИК): весь отдел.
$all = $this->actingAs($head, 'sales')->getJson('/api/sales/prospects?scope=department');
expect(collect($all->json('prospects'))->pluck('firm_name'))
->toContain('МОЯ_КАРТОЧКА')->toContain('КАРТОЧКА_МЕНЕДЖЕРА');
});
test('менеджер не может подсмотреть отдел через ?scope=department', function () {
$mgr = pr_user('manager');
$other = pr_user('manager');
SalesProspect::factory()->for($mgr, 'salesUser')->create(['firm_name' => 'СВОЯ']);
SalesProspect::factory()->for($other, 'salesUser')->create(['firm_name' => 'ЧУЖАЯ']);
$res = $this->actingAs($mgr, 'sales')->getJson('/api/sales/prospects?scope=department');
expect(collect($res->json('prospects'))->pluck('firm_name'))
->toContain('СВОЯ')->not->toContain('ЧУЖАЯ');
});
test('на личном экране начальника счётчики по менеджерам не отдаются', function () {
$head = pr_user('head');
$mgr = pr_user('manager');
SalesProspect::factory()->count(2)->for($mgr, 'salesUser')->create();
$mine = $this->actingAs($head, 'sales')->getJson('/api/sales/prospects');
expect($mine->json('manager_counts'))->toBe([]);
$all = $this->actingAs($head, 'sales')->getJson('/api/sales/prospects?scope=department');
expect($all->json('manager_counts.'.$mgr->id))->toBe(2);
});