actingAs($user, 'sales'). * Изоляция: DatabaseTransactions. Глобально подключён SharesAdminPdo (Pest.php). * DB_DATABASE=liderra_testing ОБЯЗАТЕЛЕН при запуске. * * ⚠️ ВРЕМЯ: замораживаем на день 15 — детерминизм периода/партиций. * ⚠️ ПАРТИЦИИ: balance_transactions / lead_charges партиционированы помесячно * динамически → сеем ТОЛЬКО в текущий месяц. */ uses(DatabaseTransactions::class); beforeEach(function () { $now = CarbonImmutable::create(null, null, 15, 12, 0, 0, 'Europe/Moscow'); CarbonImmutable::setTestNow($now); Carbon::setTestNow($now); }); afterEach(function () { CarbonImmutable::setTestNow(); Carbon::setTestNow(); }); // ── helpers (уникальный префикс pay_) ──────────────────────────────────────── function pay_head(): SalesUser { return SalesUser::create([ 'name' => 'Начальник '.uniqid(), 'email' => 'payhead'.uniqid().'@sales.local', 'password' => bcrypt('secret'), 'role' => 'head', 'is_active' => true, ]); } function pay_manager(float $baseSalary = 0.0, ?int $currentTariffId = null): SalesUser { return SalesUser::create([ 'name' => 'Менеджер '.uniqid(), 'email' => 'paymgr'.uniqid().'@sales.local', 'password' => bcrypt('secret'), 'role' => 'manager', 'is_active' => true, 'base_salary_rub' => $baseSalary, 'current_tariff_id' => $currentTariffId, ]); } function pay_tenant(): Tenant { return Tenant::factory()->create([ 'balance_rub' => 0.0, 'delivered_in_month' => 0, ]); } /** @param array $params */ function pay_assignment(SalesUser $user, Tenant $tenant, string $kind, array $params): SalesClientAssignment { return SalesClientAssignment::create([ 'sales_user_id' => $user->id, 'tenant_id' => $tenant->id, 'tariff_kind' => $kind, 'tariff_params' => $params, 'assigned_at' => Carbon::now('Europe/Moscow')->subMonthsNoOverflow(2)->subDays(15), ]); } /** Дата в ТЕКУЩЕМ месяце МСК (день $day, только Y-m-d). */ function pay_date(int $day): string { return CarbonImmutable::now('Europe/Moscow')->startOfMonth() ->setDay($day)->format('Y-m-d'); } function pay_leadCharge(int $tenantId, int $kopecks, ?string $at = null): void { $dt = ($at ?? pay_date(10)).' 10:00:00'; DB::table('lead_charges')->insert([ 'tenant_id' => $tenantId, 'deal_id' => fake()->numberBetween(1, 99999), 'deal_received_at' => $dt, 'tier_no' => 1, 'price_per_lead_kopecks' => $kopecks, 'charge_source' => 'rub', 'charged_at' => $dt, 'created_at' => $dt, ]); } /** Пополнение (topup) — текущий месяц. Доход по тарифу topup_step считается от пополнений. */ function pay_topup(int $tenantId, string $amountRub, ?string $at = null): void { $dt = ($at ?? pay_date(10)).' 10:00:00'; DB::table('balance_transactions')->insert([ 'tenant_id' => $tenantId, 'type' => 'topup', 'amount_rub' => $amountRub, 'amount_leads' => 0, 'balance_rub_after' => $amountRub, 'description' => 'test', 'created_at' => $dt, ]); } /** Готовая выплата в БД (через сервис-модель, минуя контроллер). */ function pay_payout(int $managerId, int $headId, string $amount, string $paidOn, ?string $comment = null): SalesPayout { return SalesPayout::create([ 'sales_user_id' => $managerId, 'amount_rub' => $amount, 'paid_on' => $paidOn, 'comment' => $comment, 'created_by' => $headId, ]); } // ── 1. store: доступ + happy path ──────────────────────────────────────────── test('менеджер POST /api/sales/payouts → 403', function () { $manager = pay_manager(); $other = pay_manager(); $this->actingAs($manager, 'sales')->postJson('/api/sales/payouts', [ 'manager_id' => $other->id, 'amount_rub' => 1000, 'paid_on' => pay_date(10), ])->assertStatus(403) ->assertJson(['message' => 'Доступно только начальнику отдела.']); }); test('head проводит выплату → 201, запись в БД, письмо менеджеру', function () { Mail::fake(); $head = pay_head(); $manager = pay_manager(); $response = $this->actingAs($head, 'sales')->postJson('/api/sales/payouts', [ 'manager_id' => $manager->id, 'amount_rub' => 12500.50, 'paid_on' => pay_date(10), 'comment' => 'Зарплата за период', ]); $response->assertCreated() ->assertJsonPath('sales_user_id', $manager->id) ->assertJsonPath('manager_name', $manager->name) ->assertJsonPath('comment', 'Зарплата за период'); expect((float) $response->json('amount_rub'))->toBe(12500.50); $this->assertDatabaseHas('sales_payouts', [ 'sales_user_id' => $manager->id, 'amount_rub' => 12500.50, 'created_by' => $head->id, ]); Mail::assertSent(SalesPayoutRecordedMail::class); }); // ── 2. store: валидация ────────────────────────────────────────────────────── test('store: manager_id указывает на head → 422', function () { $head = pay_head(); $otherHead = pay_head(); $this->actingAs($head, 'sales')->postJson('/api/sales/payouts', [ 'manager_id' => $otherHead->id, 'amount_rub' => 1000, 'paid_on' => pay_date(10), ])->assertStatus(422); }); test('store: amount_rub = 0 → 422', function () { $head = pay_head(); $manager = pay_manager(); $this->actingAs($head, 'sales')->postJson('/api/sales/payouts', [ 'manager_id' => $manager->id, 'amount_rub' => 0, 'paid_on' => pay_date(10), ])->assertStatus(422); }); test('store: без paid_on → 422', function () { $head = pay_head(); $manager = pay_manager(); $this->actingAs($head, 'sales')->postJson('/api/sales/payouts', [ 'manager_id' => $manager->id, 'amount_rub' => 1000, ])->assertStatus(422); }); // ── 3. append-only: журнал неизменяем ──────────────────────────────────────── test('append-only: UPDATE выплаты запрещён DB-триггером', function () { $head = pay_head(); $manager = pay_manager(); $payout = pay_payout($manager->id, $head->id, '5000.00', pay_date(5)); // DB::transaction создаёт SAVEPOINT внутри DatabaseTransactions — при // EXCEPTION триггера откатывается только savepoint, внешняя транзакция жива. expect(fn () => DB::transaction(fn () => DB::table('sales_payouts') ->where('id', $payout->id) ->update(['amount_rub' => '9999.00']))) ->toThrow(QueryException::class); // Значение в БД не изменилось. $this->assertDatabaseHas('sales_payouts', [ 'id' => $payout->id, 'amount_rub' => 5000.00, ]); }); test('append-only: DELETE выплаты запрещён DB-триггером', function () { $head = pay_head(); $manager = pay_manager(); $payout = pay_payout($manager->id, $head->id, '5000.00', pay_date(5)); expect(fn () => DB::transaction(fn () => DB::table('sales_payouts') ->where('id', $payout->id)->delete())) ->toThrow(QueryException::class); $this->assertDatabaseHas('sales_payouts', ['id' => $payout->id]); }); // ── 4. index: журнал (свой/все) ────────────────────────────────────────────── test('index: менеджер видит ТОЛЬКО свои выплаты', function () { $head = pay_head(); $manager = pay_manager(); $other = pay_manager(); pay_payout($manager->id, $head->id, '1000.00', pay_date(5)); pay_payout($manager->id, $head->id, '2000.00', pay_date(10)); pay_payout($other->id, $head->id, '3000.00', pay_date(7)); $response = $this->actingAs($manager, 'sales')->getJson('/api/sales/payouts'); $response->assertOk(); $data = $response->json('data'); expect($data)->toHaveCount(2); $ids = array_column($data, 'sales_user_id'); expect(array_unique($ids))->toBe([$manager->id]); }); test('index: начальник видит выплаты всех менеджеров с manager_name', function () { $head = pay_head(); $m1 = pay_manager(); $m2 = pay_manager(); pay_payout($m1->id, $head->id, '1000.00', pay_date(5)); pay_payout($m2->id, $head->id, '2000.00', pay_date(10)); $response = $this->actingAs($head, 'sales')->getJson('/api/sales/payouts'); $response->assertOk(); $data = $response->json('data'); // ≥2 — могут быть строки от параллельных helper-выплат нет (изоляция транзакцией). expect(count($data))->toBeGreaterThanOrEqual(2); $byUser = collect($data)->keyBy('sales_user_id'); expect($byUser->has($m1->id))->toBeTrue() ->and($byUser->has($m2->id))->toBeTrue() ->and($byUser[$m1->id]['manager_name'])->toBe($m1->name) ->and($byUser[$m1->id])->toHaveKeys([ 'id', 'sales_user_id', 'manager_name', 'amount_rub', 'paid_on', 'comment', 'created_at', 'creator_name', ]); }); // newest first test('index: порядок newest first (paid_on desc)', function () { $head = pay_head(); $manager = pay_manager(); pay_payout($manager->id, $head->id, '1000.00', pay_date(3)); pay_payout($manager->id, $head->id, '2000.00', pay_date(20)); $data = $this->actingAs($manager, 'sales')->getJson('/api/sales/payouts')->json('data'); expect($data[0]['paid_on'])->toBe(pay_date(20)) ->and($data[1]['paid_on'])->toBe(pay_date(3)); }); // ── 5. remaining: начислено − выплачено ────────────────────────────────────── test('remaining (head): accrued − paid_period = остаток', function () { $head = pay_head(); // Тариф topup_step 20% (оклада нет: у менеджера нет текущего тарифа daily_salary). $manager = pay_manager(baseSalary: 30000.0); $tenant = pay_tenant(); pay_assignment($manager, $tenant, 'topup_step', ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 20]]]); // Оборот 50 000 ₽ = 5 000 000 копеек (метрика oborot_rub). pay_leadCharge($tenant->id, 5_000_000, pay_date(10)); // Пополнения 50 000 ₽ → комиссия topup_step 20% = 10 000. pay_topup($tenant->id, '50000.00', pay_date(10)); // Начислено = 10 000 (оклада нет). Выплачено в периоде = 6 000. Остаток = 4 000. pay_payout($manager->id, $head->id, '6000.00', pay_date(12)); // Выплата ВНЕ периода (прошлый месяц) — в all_time войдёт, в period — нет. pay_payout($manager->id, $head->id, '5000.00', CarbonImmutable::now('Europe/Moscow')->subMonthNoOverflow()->format('Y-m-d')); $response = $this->actingAs($head, 'sales')->getJson('/api/sales/payouts/remaining?period=this'); $response->assertOk(); $row = collect($response->json('data'))->firstWhere('manager_id', $manager->id); expect($row)->not->toBeNull() ->and((float) $row['accrued_rub'])->toBe(10000.0) ->and((float) $row['paid_period_rub'])->toBe(6000.0) ->and((float) $row['paid_all_time_rub'])->toBe(11000.0) ->and((float) $row['remaining_rub'])->toBe(4000.0) ->and((float) $row['oborot_rub'])->toBe(50000.0) ->and($row['clients_count'])->toBe(1) ->and($row)->toHaveKeys([ 'manager_id', 'name', 'clients_count', 'oborot_rub', 'accrued_rub', 'paid_period_rub', 'paid_all_time_rub', 'remaining_rub', ]); }); test('remaining: менеджер (не head) → 403', function () { $manager = pay_manager(); $this->actingAs($manager, 'sales')->getJson('/api/sales/payouts/remaining') ->assertStatus(403); }); // ── 6. unauthenticated ─────────────────────────────────────────────────────── test('неаутентифицированный GET /api/sales/payouts → 401', function () { $this->getJson('/api/sales/payouts')->assertUnauthorized(); });