Files
portal/app/tests/Feature/Sales/SalesProspectApiTest.php
T
Дмитрий 41fbc4491d @
feat(sales): журнал разговоров по кандидату + переделка обоих окон воронки

Владелец: «краткое содержание разговора писать, чтобы не потерять историю»
и «переделай обоих дизайн, очень не практично, спроси у перплексити».

ЖУРНАЛ (БД v8.74, таблица sales_prospect_notes, append-only):
- kind=note — менеджер написал руками; kind=stage — автозапись о переезде
  по стадии (взял в работу, созвон, недозвон, отказ, регистрация).
  Повторное открытие карточки журнал не засоряет.
- GET/POST /api/sales/prospects/{id}/notes; лента свежими сверху, грузится
  при открытии карточки, а не вместе с доской.
- PATCH /prospects/{id} БОЛЬШЕ НЕ принимает notes: он молча затирал прошлую
  запись — ровно та потеря истории, ради которой журнал и появился.
  Нашёл rls-reviewer, закрыто тестом. Старые notes перенесены в журнал.

ДИЗАЙН (по разбору Pipedrive/HubSpot/Salesforce через Perplexity):
- Карточка 1100px, две колонки. Слева «что за фирма» + история разговоров
  с полем «о чём поговорили». Справа зона действия: «Куда звонить» (номер
  крупно, ссылкой tel:), «Результат разговора» своим фоном, контактные лица.
  Кнопки внизу окна. Пустая история объясняет, что делать.
- Форма создания разбита на разделы: Компания / Контактные лица / Заметка;
  Юрлицо и Город в одну строку; обязательных полей по-прежнему два.

Спека §19. Гейты: Pest 231/231 sales, Vitest 60/60 воронка, vue-tsc чисто,
Larastan 0 в своих файлах, rls-reviewer PASS. TDD RED→GREEN.

LEFTHOOK_EXCLUDE: larastan/cspell падают на файлах параллельной сессии
tests/Feature/Admin/*Balances*, свои файлы проверены отдельно и чисты.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-18 13:04:02 +03:00

694 lines
33 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');
$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?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?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?source=manager');
expect(collect($own->json('prospects'))->pluck('firm_name'))
->toContain('СВОЯ')->not->toContain('ОТ_НАЧАЛЬНИКА');
$all = $this->actingAs($head, 'sales')->getJson('/api/sales/prospects');
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('подтяжка по ИНН: нашли → юрлицо и город из Да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('СТАРАЯ ЗАМЕТКА');
});