feat(sales): API воронки — список (свои/все+фильтр) + результаты разговора

Этап 1 Task 3–7. GET /api/sales/prospects (менеджер видит свои; начальник —
все + ?manager_id). PATCH /prospects/{id} — переговоры (next_call_at),
недозвон (причина), отказ (причина; запрещён из stage=user). ownership 403.
8 тестов зелёные. Baseline Larastan под Pest-паттерны нового файла.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-15 16:33:29 +03:00
parent 4b7463d1d9
commit 9d8fd8d48c
5 changed files with 307 additions and 4 deletions
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Controller;
use App\Models\SalesProspect;
use App\Models\SalesUser;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
* Портал продаж воронка «Потенциальные клиенты».
*
* GET /api/sales/prospects список (менеджер: свои; head: все + ?manager_id)
* PATCH /api/sales/prospects/{id} результат разговора (переговоры/недозвон/отказ)
*
* Дизайн: docs/superpowers/specs/2026-07-15-sales-prospects-kanban-design.md §9.
*/
class SalesProspectController extends Controller
{
/** Все стадии в порядке колонок канбана. */
private const STAGES = [
'new', 'negotiation', 'registered', 'testing', 'topped_up', 'user', 'rejected', 'no_answer',
];
public function index(Request $request): JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
$query = SalesProspect::query()->orderByDesc('id');
if (! $user->isHead()) {
// Менеджер — только свои, ?manager_id игнорируется.
$query->where('sales_user_id', $user->id);
} elseif ($request->filled('manager_id')) {
// Начальник может сузить до конкретного менеджера.
$query->where('sales_user_id', (int) $request->query('manager_id'));
}
$prospects = $query->get()->map(fn (SalesProspect $p) => $this->row($p))->all();
// Группировка по стадии (для колонок), все ключи присутствуют.
$byStage = array_fill_keys(self::STAGES, []);
foreach ($prospects as $row) {
$byStage[$row['stage']][] = $row;
}
return response()->json([
'prospects' => $prospects,
'by_stage' => $byStage,
'stages' => self::STAGES,
]);
}
/**
* Записать результат разговора. Карточка переезжает по стадии сама.
* Авто-стадии (testing/topped_up/user) здесь не ставятся только Этап 3.
*/
public function update(Request $request, int $id): JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
$prospect = SalesProspect::findOrFail($id);
// Менеджер правит только свои; начальник — любые.
if (! $user->isHead() && $prospect->sales_user_id !== $user->id) {
return response()->json(['message' => 'Нет доступа к этой карточке.'], 403);
}
$action = (string) $request->input('action');
switch ($action) {
case 'negotiation':
$data = $request->validate([
'next_call_at' => ['required', 'date'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$prospect->stage = 'negotiation';
$prospect->next_call_at = $data['next_call_at'];
$prospect->reason = null;
break;
case 'no_answer':
$data = $request->validate([
'reason' => ['required', 'string', 'max:2000'],
]);
$prospect->stage = 'no_answer';
$prospect->reason = $data['reason'];
break;
case 'rejected':
if ($prospect->stage === 'user') {
throw ValidationException::withMessages([
'action' => 'Нельзя отказать действующему пользователю.',
]);
}
$data = $request->validate([
'reason' => ['required', 'string', 'max:2000'],
]);
$prospect->stage = 'rejected';
$prospect->reason = $data['reason'];
break;
default:
throw ValidationException::withMessages([
'action' => 'Неизвестный результат разговора.',
]);
}
if ($request->filled('notes')) {
$prospect->notes = (string) $request->input('notes');
}
$prospect->save();
return response()->json(['prospect' => $this->row($prospect)]);
}
/** @return array<string,mixed> */
private function row(SalesProspect $p): array
{
return [
'id' => $p->id,
'sales_user_id' => $p->sales_user_id,
'stage' => $p->stage,
'firm_name' => $p->firm_name,
'city' => $p->city,
'phone' => $p->phone,
'site' => $p->site,
'inn' => $p->inn,
'rating_label' => $p->rating_label,
'payload' => $p->payload,
'next_call_at' => $p->next_call_at?->toIso8601String(),
'reason' => $p->reason,
'registered_email' => $p->registered_email,
'notes' => $p->notes,
];
}
}
+18
View File
@@ -3978,6 +3978,24 @@ parameters:
count: 1
path: tests/Feature/Sales/SalesTariffApiTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 12
path: tests/Feature/Sales/SalesProspectApiTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesProspectApiTest.php
-
message: '#^Access to an undefined property Pest\\Mixins\\Expectation\<Illuminate\\Support\\Collection\<\(int\|string\), mixed\>\|null\>\:\:\$not\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Sales/SalesProspectApiTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
+4
View File
@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\Sales\SalesIncomeController;
use App\Http\Controllers\Api\Sales\SalesInvoiceController;
use App\Http\Controllers\Api\Sales\SalesManagersController;
use App\Http\Controllers\Api\Sales\SalesPayoutController;
use App\Http\Controllers\Api\Sales\SalesProspectController;
use App\Http\Controllers\Api\Sales\SalesTariffController;
use Illuminate\Support\Facades\Route;
@@ -271,6 +272,9 @@ Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('api/sales
Route::post('/tariffs', [SalesTariffController::class, 'store']);
Route::match(['put', 'patch'], '/tariffs/{id}', [SalesTariffController::class, 'update'])->whereNumber('id');
Route::post('/tariffs/assign', [SalesTariffController::class, 'assign']);
// Этап 1 (воронка «Потенциальные клиенты»): доски менеджера/начальника.
Route::get('/prospects', [SalesProspectController::class, 'index']);
Route::patch('/prospects/{id}', [SalesProspectController::class, 'update'])->whereNumber('id');
// Task 4.1: выплаты менеджерам (append-only журнал). remaining/store — только head.
Route::get('/payouts/remaining', [SalesPayoutController::class, 'remaining']);
Route::get('/payouts', [SalesPayoutController::class, 'index']);
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
use App\Models\SalesProspect;
use App\Models\SalesUser;
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_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('отказ: причина обязательна; из 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');
});
+4 -4
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-07-15T13:16:14.105Z
Last updated: 2026-07-15T13:25:11.930Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 3488 | MsMpEng | 6.84ч | NaNч |
| 9756 | Code | 3.15ч | 0.0ч |
| 1320 | svchost | 1.32ч | 0.0ч |
| 3488 | MsMpEng | 6.88ч | 0.0ч |
| 9756 | Code | 3.16ч | NaNч |
| 1320 | svchost | 1.33ч | 0.0ч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.