Files
portal/app/tests/Feature/Sales/SalesProspectApiTest.php
T
Дмитрий 138550207a @
feat(sales): содержание разговора — вместе с результатом; недозвону дата перезвона

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

- Блок «Куда звонить» убран: дублировал строку «Телефон общий» из данных фирмы.
- «Краткое содержание разговора» переехало последним полем в блок «Результат
  разговора» (было отдельное поле с кнопкой слева — два места для одного
  действия). Уходит параметром summary вместе с результатом, ложится в журнал
  отдельной записью kind=note. Пустое — не пишем.
- Порядок внутри одного сохранения: сначала автозапись про этап, затем
  содержание → у него больший id и в ленте оно оказывается НАД этапом.
- Недозвон получил необязательное поле «Когда перезвонить»: пишется в
  next_call_at (видно на плитке) и дописывается в автозапись журнала.

Спека §20. Гейты: Pest 236/236 sales, Vitest 59/59 воронка, vue-tsc чисто,
Larastan 0 в своих файлах. 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:20:27 +03:00

769 lines
37 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('СТАРАЯ ЗАМЕТКА');
});
// ── §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)->orderByDesc('id')->get();
expect($log)->toHaveCount(2);
// Свежая сверху — сам разговор, под ним автозапись про этап.
expect($log[0]->kind)->toBe('note');
expect($log[0]->body)->toBe('Директор на месте, просил перезвонить в понедельник с ценами.');
expect($log[1]->kind)->toBe('stage');
expect($log[1]->body)->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' => '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)->orderByDesc('id')->get();
expect($log[0]->body)->toBe('Три гудка и сброс, попробую утром.');
expect($log[1]->body)->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('Сказал, что доволен текущим.');
});