'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); });