diff --git a/app/app/Http/Controllers/Api/Sales/SalesPayoutController.php b/app/app/Http/Controllers/Api/Sales/SalesPayoutController.php new file mode 100644 index 00000000..faccd28b --- /dev/null +++ b/app/app/Http/Controllers/Api/Sales/SalesPayoutController.php @@ -0,0 +1,148 @@ +denyIfNotHead($request)) !== null) { + return $resp; + } + + /** @var SalesUser $head */ + $head = $request->user('sales'); + + $data = $request->validate([ + 'manager_id' => [ + 'required', + 'integer', + // Менеджер должен существовать И иметь роль manager (не head). + Rule::exists('sales_users', 'id')->where('role', 'manager'), + ], + 'amount_rub' => ['required', 'numeric', 'min:0.01'], + 'paid_on' => ['required', 'date'], + 'comment' => ['nullable', 'string', 'max:500'], + ]); + + $payout = $this->payouts->record( + $head, + (int) $data['manager_id'], + (string) $data['amount_rub'], + (string) $data['paid_on'], + $data['comment'] ?? null, + ); + + return response()->json($this->payouts->row($payout), 201); + } + + /** + * GET /api/sales/payouts — журнал выплат. + * + * Менеджер → только свои; начальник → все. + */ + public function index(Request $request): JsonResponse + { + /** @var SalesUser $user */ + $user = $request->user('sales'); + + $rows = $this->payouts->journal($user, ownOnly: ! $user->isHead()); + + return response()->json(['data' => $rows]); + } + + /** + * GET /api/sales/payouts/remaining — остаток к выплате по менеджерам (только head). + */ + public function remaining(Request $request): JsonResponse + { + if (($resp = $this->denyIfNotHead($request)) !== null) { + return $resp; + } + + $range = $this->resolver->resolve([ + 'kind' => (string) $request->query('period', 'this'), + 'from' => $request->query('from'), + 'to' => $request->query('to'), + ]); + + $managers = SalesUser::query() + ->where('role', 'manager') + ->with('assignments') + ->orderBy('name') + ->get(); + + $rows = $managers->map(function (SalesUser $manager) use ($range): array { + $oborot = 0.0; + foreach ($manager->assignments as $assignment) { + $oborot += $this->metrics->oborotRub((int) $assignment->tenant_id, $range); + } + + $remaining = $this->payouts->remaining($manager, $range); + + return [ + 'manager_id' => $manager->id, + 'name' => $manager->name, + 'clients_count' => $manager->assignments->count(), + 'oborot_rub' => round($oborot, 2), + 'accrued_rub' => $remaining['accrued_rub'], + 'paid_period_rub' => $remaining['paid_period_rub'], + 'paid_all_time_rub' => $remaining['paid_all_time_rub'], + 'remaining_rub' => $remaining['remaining_rub'], + ]; + })->all(); + + return response()->json(['data' => $rows]); + } + + // ── private ────────────────────────────────────────────────────────────── + + /** + * Гейт «только начальник». Возвращает 403-ответ, либо null если доступ есть. + */ + private function denyIfNotHead(Request $request): ?JsonResponse + { + /** @var SalesUser $user */ + $user = $request->user('sales'); + + if (! $user->isHead()) { + return response()->json(['message' => 'Доступно только начальнику отдела.'], 403); + } + + return null; + } +} diff --git a/app/app/Mail/Sales/SalesPayoutRecordedMail.php b/app/app/Mail/Sales/SalesPayoutRecordedMail.php new file mode 100644 index 00000000..dcd18143 --- /dev/null +++ b/app/app/Mail/Sales/SalesPayoutRecordedMail.php @@ -0,0 +1,50 @@ +email)->send(new ...). + */ +final class SalesPayoutRecordedMail extends Mailable +{ + use Queueable; + use SerializesModels; + + public function __construct( + public readonly SalesPayout $payout, + public readonly SalesUser $manager, + ) {} + + public function envelope(): Envelope + { + return new Envelope( + subject: 'Проведена выплата', + ); + } + + public function content(): Content + { + return new Content( + view: 'mail.sales.payout-recorded', + with: [ + 'managerName' => $this->manager->name, + 'amountRub' => (float) $this->payout->amount_rub, + 'paidOn' => $this->payout->paid_on->format('d.m.Y'), + 'comment' => $this->payout->comment, + ], + ); + } +} diff --git a/app/app/Services/Sales/SalesPayoutService.php b/app/app/Services/Sales/SalesPayoutService.php new file mode 100644 index 00000000..181a506a --- /dev/null +++ b/app/app/Services/Sales/SalesPayoutService.php @@ -0,0 +1,137 @@ += range.start->toDateString() AND paid_on <= range.end->toDateString() + * (закрытый интервал по календарным дням — paid_on без времени). + * + * Работает через DEFAULT connection (под middleware admin-db), как и остальные + * sales-сервисы. + */ +class SalesPayoutService +{ + public function __construct( + private readonly SalesEarningsService $earnings, + ) {} + + /** + * Провести выплату менеджеру: создать append-only запись + письмо менеджеру. + */ + public function record( + SalesUser $head, + int $managerId, + float|string $amount, + string $paidOn, + ?string $comment, + ): SalesPayout { + $payout = SalesPayout::create([ + 'sales_user_id' => $managerId, + 'amount_rub' => (string) $amount, + 'paid_on' => $paidOn, + 'comment' => $comment, + 'created_by' => $head->id, + ]); + + // $timestamps=false → created_at заполняется DB-дефолтом, но не хидрируется + // в свежую модель. Подтягиваем из БД, чтобы row() отдал created_at. + $payout->refresh(); + + /** @var SalesUser $manager */ + $manager = SalesUser::query()->findOrFail($managerId); + + Mail::to($manager->email)->send(new SalesPayoutRecordedMail($payout, $manager)); + + return $payout; + } + + /** + * Начислено / выплачено / остаток для менеджера за период. + * + * accrued_rub — начислено за период (SalesEarningsService::forManager). + * paid_period_rub — Σ выплат с paid_on ∈ [range.start .. range.end] (по дате). + * paid_all_time_rub — Σ всех выплат менеджера за всё время. + * remaining_rub — accrued_rub − paid_period_rub (round 2), может быть < 0. + * + * @return array{accrued_rub: float, paid_period_rub: float, paid_all_time_rub: float, remaining_rub: float} + */ + public function remaining(SalesUser $manager, SalesPeriodRange $range): array + { + $accrued = $this->earnings->forManager($manager, $range); + + $paidPeriod = (float) SalesPayout::query() + ->where('sales_user_id', $manager->id) + ->where('paid_on', '>=', $range->start->toDateString()) + ->where('paid_on', '<=', $range->end->toDateString()) + ->sum('amount_rub'); + + $paidAllTime = (float) SalesPayout::query() + ->where('sales_user_id', $manager->id) + ->sum('amount_rub'); + + return [ + 'accrued_rub' => round($accrued, 2), + 'paid_period_rub' => round($paidPeriod, 2), + 'paid_all_time_rub' => round($paidAllTime, 2), + 'remaining_rub' => round($accrued - $paidPeriod, 2), + ]; + } + + /** + * Журнал выплат. $ownOnly (менеджер) → только свои; иначе (head) → все. + * Порядок newest first: paid_on desc, id desc. + * + * @return list + */ + public function journal(SalesUser $viewer, bool $ownOnly): array + { + $query = SalesPayout::query() + ->with(['salesUser', 'creator']) + ->orderByDesc('paid_on') + ->orderByDesc('id'); + + if ($ownOnly) { + $query->where('sales_user_id', $viewer->id); + } + + return $query->get() + ->map(fn (SalesPayout $p): array => $this->row($p)) + ->all(); + } + + /** + * Единый формат строки выплаты (для journal и ответа store). + * + * @return array{id: int, sales_user_id: int, manager_name: string|null, amount_rub: float, paid_on: string, comment: string|null, created_at: string, creator_name: string|null} + */ + public function row(SalesPayout $payout): array + { + return [ + 'id' => $payout->id, + 'sales_user_id' => $payout->sales_user_id, + 'manager_name' => $payout->salesUser?->name, + 'amount_rub' => (float) $payout->amount_rub, + 'paid_on' => $payout->paid_on->format('Y-m-d'), + 'comment' => $payout->comment, + 'created_at' => $payout->created_at->toIso8601String(), + 'creator_name' => $payout->creator?->name, + ]; + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index fb61eaed..85c8fc40 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -3372,3 +3372,18 @@ parameters: identifier: method.notFound count: 1 path: tests/Feature/Sales/SalesTariffApiTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 10 + path: tests/Feature/Sales/SalesPayoutTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertDatabaseHas\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Sales/SalesPayoutTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Sales/SalesPayoutTest.php diff --git a/app/resources/views/mail/sales/payout-recorded.blade.php b/app/resources/views/mail/sales/payout-recorded.blade.php new file mode 100644 index 00000000..b0a913ca --- /dev/null +++ b/app/resources/views/mail/sales/payout-recorded.blade.php @@ -0,0 +1,7 @@ +

Здравствуйте, {{ $managerName }}!

+

Вам проведена выплата на сумму {{ number_format($amountRub, 2, ',', ' ') }} ₽.

+

Дата выплаты: {{ $paidOn }}

+@if($comment) +

Комментарий: {{ $comment }}

+@endif +

Подробности — в портале отдела продаж.

diff --git a/app/routes/web.php b/app/routes/web.php index 250200d6..245c1f29 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\Sales\SalesAttachmentController; use App\Http\Controllers\Api\Sales\SalesAuthController; use App\Http\Controllers\Api\Sales\SalesClientsController; +use App\Http\Controllers\Api\Sales\SalesPayoutController; use App\Http\Controllers\Api\Sales\SalesTariffController; use Illuminate\Support\Facades\Route; @@ -256,7 +257,11 @@ 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']); - // income, payouts, invoices, managers, dashboard + // Task 4.1: выплаты менеджерам (append-only журнал). remaining/store — только head. + Route::get('/payouts/remaining', [SalesPayoutController::class, 'remaining']); + Route::get('/payouts', [SalesPayoutController::class, 'index']); + Route::post('/payouts', [SalesPayoutController::class, 'store']); + // income, invoices, managers, dashboard }); // Plan 4 Task 11: tenant charges ledger (read-only + CSV export). diff --git a/app/tests/Feature/Sales/SalesPayoutTest.php b/app/tests/Feature/Sales/SalesPayoutTest.php new file mode 100644 index 00000000..00990389 --- /dev/null +++ b/app/tests/Feature/Sales/SalesPayoutTest.php @@ -0,0 +1,344 @@ +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, + ]); +} + +/** Готовая выплата в БД (через сервис-модель, минуя контроллер). */ +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(); + // Оклад 30 000 + процент 20% от оборота. + $manager = pay_manager(baseSalary: 30000.0); + $tenant = pay_tenant(); + pay_assignment($manager, $tenant, 'percent_oborot', ['rate' => 20]); + // Оборот 50 000 ₽ = 5 000 000 копеек → комиссия 20% = 10 000. + pay_leadCharge($tenant->id, 5_000_000, pay_date(10)); + + // Начислено = оклад 30 000 + 10 000 = 40 000. + // Выплачено в периоде = 15 000. Остаток = 25 000. + pay_payout($manager->id, $head->id, '15000.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(40000.0) + ->and((float) $row['paid_period_rub'])->toBe(15000.0) + ->and((float) $row['paid_all_time_rub'])->toBe(20000.0) + ->and((float) $row['remaining_rub'])->toBe(25000.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(); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index eed49171..a0934ef1 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-02T16:32:22.546Z +Last updated: 2026-07-02T16:51:07.057Z | Контролёр | Состояние | Детали | |---|---|---| @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 9812 | Code | 9.88ч | NaNч | -| 3412 | MsMpEng | 9.79ч | 0.0ч | -| 4 | System | 2.59ч | NaNч | +| 9812 | Code | 9.96ч | 0.0ч | +| 3412 | MsMpEng | 9.94ч | 1328032.9ч | +| 4 | System | 2.62ч | NaNч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.