diff --git a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php new file mode 100644 index 00000000..b115ff89 --- /dev/null +++ b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php @@ -0,0 +1,143 @@ +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 */ + 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, + ]; + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 32691976..fa8d2c20 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -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\\|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 diff --git a/app/routes/web.php b/app/routes/web.php index a8c36dd6..bf95cdf9 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -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']); diff --git a/app/tests/Feature/Sales/SalesProspectApiTest.php b/app/tests/Feature/Sales/SalesProspectApiTest.php new file mode 100644 index 00000000..2c83ced7 --- /dev/null +++ b/app/tests/Feature/Sales/SalesProspectApiTest.php @@ -0,0 +1,138 @@ + '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'); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 7ef97b0b..0ac65e94 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -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-сессий.