Files
portal/app/tests/Feature/Sales/SalesProspectMoveTest.php
T
Дмитрий 53ebcd9fe0 feat(воронка продаж): журнал движений карточки + место под корзину
Стадию меняют два разных места — результат разговора менеджера и автопереезд
по деньгам. Джоба не оставляла следа вообще, поэтому «что менялось за день»
было не из чего построить. Запись движения перенесена в событие модели: один
шов на всех, включая любой будущий третий источник.

- sales_prospect_moves — журнал всех движений (append-only, с GRANT'ами ролям);
- prev_stage на карточке — откуда приехала (кормит счётчик «69/1» у «Отказа»);
- stage += 'trash' — место под колонку «Корзина».

Сторож проверен вырезанием: без записи движения краснеют 4 теста, в том числе
тест джобы и тест API.
2026-08-01 12:05:04 +03:00

122 lines
4.2 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\Sales\SalesProspectsAdvanceJob;
use App\Models\SalesProspect;
use App\Models\SalesProspectMove;
use App\Models\SalesUser;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
function mv_user(string $role = 'manager'): SalesUser
{
return SalesUser::create([
'name' => 'MV '.uniqid(), 'email' => 'mv'.uniqid().'@s.local',
'password' => bcrypt('secret'), 'role' => $role, 'is_active' => true,
]);
}
/** @return list<array{from: ?string, to: string, by: ?int}> */
function mv_moves(SalesProspect $p): array
{
return SalesProspectMove::query()
->where('prospect_id', $p->id)
->orderBy('id')
->get()
->map(fn (SalesProspectMove $m) => [
'from' => $m->from_stage,
'to' => $m->to_stage,
'by' => $m->sales_user_id,
])
->all();
}
// ── событие модели: журнал движений ───────────────────────────────────────────
test('заведение карточки пишет первое движение — из ниоткуда в стадию', function () {
$mgr = mv_user();
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']);
expect(mv_moves($p))->toBe([['from' => null, 'to' => 'new', 'by' => null]]);
});
test('смена стадии пишет движение и заполняет prev_stage', function () {
$mgr = mv_user();
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']);
$p->stage = 'in_work';
$p->save();
expect($p->prev_stage)->toBe('new');
expect(mv_moves($p))->toBe([
['from' => null, 'to' => 'new', 'by' => null],
['from' => 'new', 'to' => 'in_work', 'by' => null],
]);
});
test('сохранение БЕЗ смены стадии движения не пишет', function () {
$mgr = mv_user();
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']);
$p->city = 'Томск';
$p->save();
expect(mv_moves($p))->toHaveCount(1);
});
test('несколько переездов подряд ложатся цепочкой, prev_stage — только последний', function () {
$mgr = mv_user();
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']);
foreach (['in_work', 'negotiation', 'manual_testing'] as $stage) {
$p->stage = $stage;
$p->save();
}
expect($p->prev_stage)->toBe('negotiation');
expect(array_column(mv_moves($p), 'to'))->toBe(['new', 'in_work', 'negotiation', 'manual_testing']);
});
// ── движения пишутся ОТОВСЮДУ: и от менеджера, и от джобы ─────────────────────
test('результат разговора через API пишет движение с автором-менеджером', function () {
$mgr = mv_user();
$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-08-05T12:00:00',
])
->assertOk();
$moves = mv_moves($p);
expect(end($moves))->toBe(['from' => 'in_work', 'to' => 'negotiation', 'by' => $mgr->id]);
});
test('автопереезд по деньгам пишет движение без автора', function () {
$mgr = mv_user();
$tenant = Tenant::factory()->create();
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create([
'stage' => 'registered',
'linked_tenant_id' => $tenant->id,
]);
DB::table('balance_transactions')->insert([
'tenant_id' => $tenant->id,
'type' => 'topup',
'amount_rub' => 5000,
'description' => 'пополнение',
'created_at' => now(),
]);
(new SalesProspectsAdvanceJob)->handle();
$moves = mv_moves($p);
expect(end($moves))->toBe(['from' => 'registered', 'to' => 'topped_up', 'by' => null]);
});