Files
portal/app/tests/Feature/Sales/SalesProspectApiTest.php
T

884 lines
42 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('менеджер не может через 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('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('создание без ИНН → 422', function () {
$mgr = pr_user('manager');
$this->actingAs($mgr, 'sales')
->postJson('/api/sales/prospects', ['firm_name' => 'Без ИНН'])
->assertStatus(422)
->assertJsonValidationErrors('inn');
});
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);
});