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:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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-сессий.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user