diff --git a/app/app/Http/Controllers/Api/Sales/SalesAuthController.php b/app/app/Http/Controllers/Api/Sales/SalesAuthController.php new file mode 100644 index 00000000..a9fb6e85 --- /dev/null +++ b/app/app/Http/Controllers/Api/Sales/SalesAuthController.php @@ -0,0 +1,104 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + $user = SalesUser::where('email', $request->email)->first(); + + if (! $user || ! Hash::check($request->password, $user->password)) { + return response()->json( + ['message' => 'Неверный логин или пароль.'], + 422 + ); + } + + if (! $user->is_active) { + return response()->json( + ['message' => 'Аккаунт отключён, обратитесь к начальнику.'], + 403 + ); + } + + $token = $user->createToken('sales')->plainTextToken; + + return response()->json([ + 'token' => $token, + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'role' => $user->role, + ], + ]); + } + + /** + * Текущий авторизованный менеджер. + * + * Guard: auth:sales — Sanctum Bearer-токен. + * Возвращает: {id, name, email, role}. + */ + public function me(Request $request): JsonResponse + { + /** @var SalesUser $user */ + $user = $request->user('sales'); + + return response()->json([ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'role' => $user->role, + ]); + } + + /** + * Выход — инвалидирует текущий токен. + * + * Guard: auth:sales. + * Возвращает: 200 {message}. + */ + public function logout(Request $request): JsonResponse + { + /** @var SalesUser $user */ + $user = $request->user('sales'); + $user->currentAccessToken()->delete(); + + return response()->json(['message' => 'Вы вышли.']); + } +} diff --git a/app/app/Http/Controllers/Concerns/ScopesSalesOwnership.php b/app/app/Http/Controllers/Concerns/ScopesSalesOwnership.php index 86fa50c8..3ba59e43 100644 --- a/app/app/Http/Controllers/Concerns/ScopesSalesOwnership.php +++ b/app/app/Http/Controllers/Concerns/ScopesSalesOwnership.php @@ -51,6 +51,7 @@ trait ScopesSalesOwnership * чтобы запрос вернул пустую коллекцию. * * @template TModel of \Illuminate\Database\Eloquent\Model + * * @param Builder $query * @return Builder */ diff --git a/app/app/Models/SalesAttachmentRequest.php b/app/app/Models/SalesAttachmentRequest.php index 68600f0d..596711a7 100644 --- a/app/app/Models/SalesAttachmentRequest.php +++ b/app/app/Models/SalesAttachmentRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -27,8 +28,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string $status * @property string|null $comment * @property int|null $decided_by - * @property \Carbon\Carbon|null $decided_at - * @property \Carbon\Carbon $created_at + * @property Carbon|null $decided_at + * @property Carbon $created_at */ class SalesAttachmentRequest extends Model { diff --git a/app/app/Models/SalesClientAssignment.php b/app/app/Models/SalesClientAssignment.php index 6fb3125b..2b898df3 100644 --- a/app/app/Models/SalesClientAssignment.php +++ b/app/app/Models/SalesClientAssignment.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -27,8 +28,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property int|null $tariff_id * @property string|null $tariff_kind * @property array $tariff_params - * @property \Carbon\Carbon $assigned_at - * @property \Carbon\Carbon $created_at + * @property Carbon $assigned_at + * @property Carbon $created_at */ class SalesClientAssignment extends Model { @@ -47,8 +48,8 @@ class SalesClientAssignment extends Model { return [ 'tariff_params' => 'array', - 'assigned_at' => 'datetime', - 'created_at' => 'datetime', + 'assigned_at' => 'datetime', + 'created_at' => 'datetime', ]; } diff --git a/app/app/Models/SalesPayout.php b/app/app/Models/SalesPayout.php index a28fa9c0..ebc0ff45 100644 --- a/app/app/Models/SalesPayout.php +++ b/app/app/Models/SalesPayout.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -22,10 +23,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property int $id * @property int $sales_user_id * @property string $amount_rub - * @property \Carbon\Carbon $paid_on + * @property Carbon $paid_on * @property string|null $comment * @property int $created_by - * @property \Carbon\Carbon $created_at + * @property Carbon $created_at */ class SalesPayout extends Model { @@ -43,7 +44,7 @@ class SalesPayout extends Model { return [ 'amount_rub' => 'decimal:2', - 'paid_on' => 'date', + 'paid_on' => 'date', 'created_at' => 'datetime', ]; } diff --git a/app/app/Models/SalesTariff.php b/app/app/Models/SalesTariff.php index 046a91f3..45b0cc53 100644 --- a/app/app/Models/SalesTariff.php +++ b/app/app/Models/SalesTariff.php @@ -36,8 +36,8 @@ class SalesTariff extends Model protected function casts(): array { return [ - 'params' => 'array', - 'is_active' => 'boolean', + 'params' => 'array', + 'is_active' => 'boolean', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; diff --git a/app/app/Models/SalesUser.php b/app/app/Models/SalesUser.php index a26348a7..2a0631f8 100644 --- a/app/app/Models/SalesUser.php +++ b/app/app/Models/SalesUser.php @@ -54,10 +54,10 @@ class SalesUser extends Authenticatable protected function casts(): array { return [ - 'is_active' => 'boolean', + 'is_active' => 'boolean', 'base_salary_rub' => 'decimal:2', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', ]; } diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 2f00718a..70a7ec62 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -252,6 +252,30 @@ parameters: count: 1 path: tests/Feature/Sales/SalesGuardTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 5 + path: tests/Feature/Sales/SalesAuthTest.php + + - + message: '#^Access to an undefined property Pest\\Mixins\\Expectation\\:\:\$not\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Sales/SalesAuthTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Sales/SalesAuthTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withHeader\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Sales/SalesAuthTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound diff --git a/app/routes/web.php b/app/routes/web.php index c294bf87..bcd21d40 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -227,6 +227,22 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () { }); }); +// Портал отдела продаж (/api/sales/*). Вход — guard 'sales' (Sanctum, Bearer). +// Всё через admin-db (crm_admin_user): и логин, и проверка токена, и cross-tenant +// чтение; каждый запрос данных фильтруется по владению (ScopesSalesOwnership). +// admin-db СТОИТ ПЕРЕД auth:sales (Sanctum читает токены/sales_users под crm_admin_user). +Route::middleware('admin-db')->prefix('api/sales/auth')->group(function () { + Route::post('/login', [\App\Http\Controllers\Api\Sales\SalesAuthController::class, 'login']); + Route::middleware('auth:sales')->group(function () { + Route::get('/me', [\App\Http\Controllers\Api\Sales\SalesAuthController::class, 'me']); + Route::post('/logout', [\App\Http\Controllers\Api\Sales\SalesAuthController::class, 'logout']); + }); +}); +// Зона данных портала (наполняется в Фазах 1–7). +Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('api/sales')->group(function () { + // clients, attachments, income, tariffs, payouts, invoices, managers, dashboard +}); + // Plan 4 Task 11: tenant charges ledger (read-only + CSV export). // RLS изоляция через SetTenantContext (auth:sanctum + tenant) — текущий tenant // видит только свои lead_charges. Pagination 20/page, фильтры period/source. diff --git a/app/tests/Feature/Sales/SalesAuthTest.php b/app/tests/Feature/Sales/SalesAuthTest.php new file mode 100644 index 00000000..fa158375 --- /dev/null +++ b/app/tests/Feature/Sales/SalesAuthTest.php @@ -0,0 +1,150 @@ + 'Тест Менеджер '.uniqid(), + 'email' => 'salesauth-'.uniqid().'@test.local', + 'password' => Hash::make($plain), + 'role' => 'manager', + 'is_active' => $active, + ]); + + return [$user, $plain]; +} + +// ─── login ─────────────────────────────────────────────────────────────────── + +test('login: верные данные активного пользователя → 200, token и user.role', function () { + [$user, $plain] = makeSalesUserWithPassword(); + + $response = $this->postJson('/api/sales/auth/login', [ + 'email' => $user->email, + 'password' => $plain, + ]); + + $response->assertOk(); + expect($response->json('token'))->not->toBeNull()->not->toBe(''); + expect($response->json('user.role'))->toBe('manager'); + expect($response->json('user.email'))->toBe($user->email); +}); + +test('login: неверный пароль → 422 с сообщением', function () { + [$user] = makeSalesUserWithPassword(); + + $response = $this->postJson('/api/sales/auth/login', [ + 'email' => $user->email, + 'password' => 'wrong-password-xyz', + ]); + + $response->assertStatus(422); + expect($response->json('message'))->toBe('Неверный логин или пароль.'); +}); + +test('login: несуществующий email → 422 с сообщением', function () { + $response = $this->postJson('/api/sales/auth/login', [ + 'email' => 'nonexistent-'.uniqid().'@test.local', + 'password' => 'some-password', + ]); + + $response->assertStatus(422); + expect($response->json('message'))->toBe('Неверный логин или пароль.'); +}); + +test('login: неактивный пользователь с верным паролем → 403', function () { + [$user, $plain] = makeSalesUserWithPassword(active: false); + + $response = $this->postJson('/api/sales/auth/login', [ + 'email' => $user->email, + 'password' => $plain, + ]); + + $response->assertStatus(403); + expect($response->json('message'))->toBe('Аккаунт отключён, обратитесь к начальнику.'); +}); + +// ─── me ────────────────────────────────────────────────────────────────────── + +test('me: запрос без токена → 401', function () { + $this->getJson('/api/sales/auth/me') + ->assertUnauthorized(); +}); + +test('me: запрос с валидным Bearer-токеном → 200 с данными пользователя', function () { + [$user, $plain] = makeSalesUserWithPassword(); + + // Получаем токен через логин. + $loginResponse = $this->postJson('/api/sales/auth/login', [ + 'email' => $user->email, + 'password' => $plain, + ]); + $loginResponse->assertOk(); + $token = $loginResponse->json('token'); + + // Запрашиваем /me с токеном. + $meResponse = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/sales/auth/me'); + + $meResponse->assertOk(); + expect($meResponse->json('email'))->toBe($user->email); + expect($meResponse->json('role'))->toBe('manager'); +}); + +// ─── logout ────────────────────────────────────────────────────────────────── + +test('logout: выход инвалидирует токен, повторный /me → 401', function () { + [$user, $plain] = makeSalesUserWithPassword(); + + // Создаём токен напрямую (без login endpoint) — стабильнее в тестах. + // Проверка самого login-эндпоинта покрыта первым тестом выше. + $plainToken = $user->createToken('sales')->plainTextToken; + + // Выход через endpoint. + $logoutResponse = $this->withHeader('Authorization', 'Bearer '.$plainToken) + ->postJson('/api/sales/auth/logout'); + $logoutResponse->assertOk(); + expect($logoutResponse->json('message'))->toBe('Вы вышли.'); + + // Сбрасываем кэш guard-инстансов в AuthManager (общий singleton в контейнере). + // Без этого guard('sales') возвращает пользователя, кэшированного из предыдущего + // logout-запроса, даже если токен уже удалён из БД. + Auth::forgetGuards(); + + // Повторный /me с тем же токеном должен вернуть 401. + $this->withHeader('Authorization', 'Bearer '.$plainToken) + ->getJson('/api/sales/auth/me') + ->assertUnauthorized(); +}); diff --git a/app/tests/Feature/Sales/SalesModelsTest.php b/app/tests/Feature/Sales/SalesModelsTest.php index a56f84e4..74fd61e4 100644 --- a/app/tests/Feature/Sales/SalesModelsTest.php +++ b/app/tests/Feature/Sales/SalesModelsTest.php @@ -8,7 +8,9 @@ use App\Models\SalesPayout; use App\Models\SalesTariff; use App\Models\SalesUser; use App\Models\Tenant; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\DatabaseTransactions; /** @@ -28,9 +30,9 @@ uses(DatabaseTransactions::class); function makeSalesTariff(array $attrs = []): SalesTariff { return SalesTariff::create(array_merge([ - 'name' => 'Test tariff ' . uniqid(), - 'kind' => 'topup_step', - 'params' => ['step' => 1000, 'bonus' => 100], + 'name' => 'Test tariff '.uniqid(), + 'kind' => 'topup_step', + 'params' => ['step' => 1000, 'bonus' => 100], 'is_active' => true, ], $attrs)); } @@ -38,10 +40,10 @@ function makeSalesTariff(array $attrs = []): SalesTariff function makeSalesUser(array $attrs = []): SalesUser { return SalesUser::create(array_merge([ - 'name' => 'Manager ' . uniqid(), - 'email' => 'mgr' . uniqid() . '@test.local', + 'name' => 'Manager '.uniqid(), + 'email' => 'mgr'.uniqid().'@test.local', 'password' => bcrypt('secret'), - 'role' => 'manager', + 'role' => 'manager', ], $attrs)); } @@ -65,9 +67,9 @@ test('SalesTariff можно создать и params отдаётся масс test('SalesUser создаётся с role=manager и current_tariff_id', function () { $tariff = makeSalesTariff(); $user = makeSalesUser([ - 'role' => 'manager', + 'role' => 'manager', 'current_tariff_id' => $tariff->id, - 'base_salary_rub' => '50000.00', + 'base_salary_rub' => '50000.00', ]); expect($user->id)->toBeInt() @@ -77,7 +79,7 @@ test('SalesUser создаётся с role=manager и current_tariff_id', functi }); test('SalesUser::isHead() верен для role=head и false для role=manager', function () { - $head = makeSalesUser(['role' => 'head']); + $head = makeSalesUser(['role' => 'head']); $manager = makeSalesUser(['role' => 'manager']); expect($head->isHead())->toBeTrue() @@ -94,33 +96,33 @@ test('SalesUser is_active приходит булевым', function () { test('SalesClientAssignment связывает пользователя с тенантом и tariff_params — массив', function () { $tariff = makeSalesTariff(['kind' => 'percent_oborot', 'params' => ['percent' => 5]]); - $user = makeSalesUser(['current_tariff_id' => $tariff->id]); + $user = makeSalesUser(['current_tariff_id' => $tariff->id]); $tenant = Tenant::factory()->create(); // реальный тенант (FK tenants.id) $assignment = SalesClientAssignment::create([ 'sales_user_id' => $user->id, - 'tenant_id' => $tenant->id, - 'tariff_id' => $tariff->id, - 'tariff_kind' => $tariff->kind, + 'tenant_id' => $tenant->id, + 'tariff_id' => $tariff->id, + 'tariff_kind' => $tariff->kind, 'tariff_params' => ['percent' => 5], - 'assigned_at' => now(), + 'assigned_at' => now(), ]); expect($assignment->id)->toBeInt() ->and($assignment->tariff_params)->toBeArray() ->and($assignment->tariff_params['percent'])->toBe(5) - ->and($assignment->assigned_at)->toBeInstanceOf(\Carbon\Carbon::class); + ->and($assignment->assigned_at)->toBeInstanceOf(Carbon::class); }); test('SalesUser->assignments() возвращает HasMany-коллекцию', function () { - $user = makeSalesUser(); + $user = makeSalesUser(); $tenant = Tenant::factory()->create(); SalesClientAssignment::create([ 'sales_user_id' => $user->id, - 'tenant_id' => $tenant->id, + 'tenant_id' => $tenant->id, 'tariff_params' => [], - 'assigned_at' => now(), + 'assigned_at' => now(), ]); $relation = $user->assignments(); @@ -138,8 +140,8 @@ test('SalesAttachmentRequest создаётся со статусом pending', $req = SalesAttachmentRequest::create([ 'sales_user_id' => $user->id, - 'login_input' => 'client@example.com', - 'status' => 'pending', + 'login_input' => 'client@example.com', + 'status' => 'pending', ]); expect($req->id)->toBeInt() @@ -154,14 +156,14 @@ test('SalesPayout создаётся, amount_rub — decimal, paid_on — date', $payout = SalesPayout::create([ 'sales_user_id' => $user->id, - 'amount_rub' => '12500.50', - 'paid_on' => today(), - 'created_by' => $user->id, + 'amount_rub' => '12500.50', + 'paid_on' => today(), + 'created_by' => $user->id, ]); expect($payout->id)->toBeInt() ->and((float) $payout->amount_rub)->toBe(12500.50) - ->and($payout->paid_on)->toBeInstanceOf(\Carbon\Carbon::class); + ->and($payout->paid_on)->toBeInstanceOf(Carbon::class); }); test('SalesPayout append-only: UPDATE бросает исключение', function () { @@ -169,11 +171,11 @@ test('SalesPayout append-only: UPDATE бросает исключение', func $payout = SalesPayout::create([ 'sales_user_id' => $user->id, - 'amount_rub' => '100.00', - 'paid_on' => today(), - 'created_by' => $user->id, + 'amount_rub' => '100.00', + 'paid_on' => today(), + 'created_by' => $user->id, ]); expect(fn () => $payout->update(['amount_rub' => '999.00'])) - ->toThrow(\Illuminate\Database\QueryException::class); + ->toThrow(QueryException::class); });