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>
@
694 lines
33 KiB
PHP
694 lines
33 KiB
PHP
<?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('СТАРАЯ ЗАМЕТКА');
|
||
});
|