Files
portal/app/tests/Feature/Sales/SalesInvoiceTest.php
T
Дмитрий 72f6a6ae89 feat(sales): счета — список + отметка оплаты (бэк, переиспользование)
Task 5.1a — SalesInvoiceController (index, markPaid), только head.
- index: список saas_invoices (структура 1:1 с AdminInvoiceController —
  фильтры status/search, пагинация).
- markPaid: делегирует InvoicePaymentService::markPaid (идемпотентный
  claim issued→paid + зачисление баланса + акт + письмо) — логика НЕ
  дублируется.
Head-гейт перед обоими методами (менеджер → 403 до обращения к сервису).

Тесты 7/7 (вкл. пополнение баланса и идемпотентность повторной отметки),
sales-набор 158/158, Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:38:33 +03:00

180 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\SaasTransaction;
use App\Models\SaasUpdDocument;
use App\Models\SalesUser;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
/**
* TDD: SalesInvoiceController — счета в зоне продаж (Task 5.1a, бэк).
*
* GET /api/sales/invoices → head: список счетов (saas_invoices + tenant)
* POST /api/sales/invoices/{id}/mark-paid → head: отметка оплаты (делегат InvoicePaymentService)
*
* Оба метода — ТОЛЬКО начальник отдела (role=head). Менеджер → 403.
* Зачисление НЕ дублируется: делегируется InvoicePaymentService::markPaid
* (идемпотентный claim issued→paid + баланс + акт + письмо, под tenant RLS-контекстом).
*
* Аутентификация: $this->actingAs($user, 'sales').
* Изоляция: DatabaseTransactions. Глобально подключён SharesAdminPdo (Pest.php).
* DB_DATABASE=liderra_testing ОБЯЗАТЕЛЕН при запуске.
*/
uses(DatabaseTransactions::class);
// ── helpers (уникальный префикс sinv_) ───────────────────────────────────────
function sinv_head(): SalesUser
{
return SalesUser::create([
'name' => 'Начальник '.uniqid(),
'email' => 'sinvhead'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'head',
'is_active' => true,
]);
}
function sinv_manager(): SalesUser
{
return SalesUser::create([
'name' => 'Менеджер '.uniqid(),
'email' => 'sinvmgr'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'manager',
'is_active' => true,
]);
}
/**
* Счёт issued + тенант + получатель письма (зеркалит seedPaidScenario из
* InvoiceMarkPaidTest — тот же рецепт tenant/legal_entity/saas_invoice).
*
* @return array{0: Tenant, 1: SaasInvoice}
*/
function sinv_scenario(string $balance = '100.00', string $amount = '1500.00', string $status = SaasInvoice::STATUS_ISSUED): array
{
$tenant = Tenant::factory()->create(['balance_rub' => $balance]);
User::factory()->create(['tenant_id' => $tenant->id]);
// Единственный default LE на тест: unique-индекс idx_legal_entities_default
// допускает лишь одну строку с is_default=true, поэтому переиспользуем.
$le = LegalEntity::query()->where('is_default', true)->first()
?? LegalEntity::create([
'code' => 'mp_'.uniqid(), 'name' => 'ИП Лидерра', 'legal_form' => 'IP',
'inn' => '770000000099', 'is_default' => true,
]);
$invoice = SaasInvoice::create([
'tenant_id' => $tenant->id, 'legal_entity_id' => $le->id,
'invoice_number' => 'СЧ-2026-'.substr(uniqid(), -6), 'payer_type' => 'legal',
'payer_name' => 'ООО Плательщик', 'payer_inn' => '5000000000',
'amount_net' => $amount, 'amount_total' => $amount,
'status' => $status, 'issued_at' => now(), 'expires_at' => now()->addDays(5),
]);
return [$tenant, $invoice];
}
// ── 1. доступ: менеджер (не head) → 403 ──────────────────────────────────────
test('менеджер GET /api/sales/invoices → 403', function () {
$manager = sinv_manager();
$this->actingAs($manager, 'sales')->getJson('/api/sales/invoices')
->assertStatus(403)
->assertJson(['message' => 'Доступно только начальнику отдела.']);
});
test('менеджер POST /api/sales/invoices/{id}/mark-paid → 403', function () {
$manager = sinv_manager();
[, $invoice] = sinv_scenario();
$this->actingAs($manager, 'sales')->postJson("/api/sales/invoices/{$invoice->id}/mark-paid")
->assertStatus(403)
->assertJson(['message' => 'Доступно только начальнику отдела.']);
});
test('неаутентифицированный GET /api/sales/invoices → 401', function () {
$this->getJson('/api/sales/invoices')->assertUnauthorized();
});
// ── 2. index: список счетов + поля + фильтр status ───────────────────────────
test('head: index отдаёт список счетов с ожидаемыми полями', function () {
$head = sinv_head();
[$tenant, $invoice] = sinv_scenario('0.00', '2500.00');
$response = $this->actingAs($head, 'sales')->getJson('/api/sales/invoices');
$response->assertOk();
$row = collect($response->json('data'))->firstWhere('id', $invoice->id);
expect($row)->not->toBeNull()
->and($row['invoice_number'])->toBe($invoice->invoice_number)
->and((float) $row['amount_total'])->toBe(2500.0)
->and($row['status'])->toBe(SaasInvoice::STATUS_ISSUED)
->and($row['payer_name'])->toBe('ООО Плательщик')
->and($row)->toHaveKeys([
'id', 'invoice_number', 'amount_total', 'status',
'tenant_name', 'payer_name', 'issued_at', 'expires_at',
]);
expect($response->json('meta'))->toHaveKeys(['current_page', 'last_page', 'total', 'per_page']);
});
test('head: index фильтр status=paid отсекает issued-счета', function () {
$head = sinv_head();
[, $issued] = sinv_scenario('0.00', '1000.00', SaasInvoice::STATUS_ISSUED);
[, $paid] = sinv_scenario('0.00', '2000.00', SaasInvoice::STATUS_PAID);
$data = $this->actingAs($head, 'sales')
->getJson('/api/sales/invoices?status=paid')->json('data');
$ids = array_column($data, 'id');
expect($ids)->toContain($paid->id)
->and($ids)->not->toContain($issued->id);
});
// ── 3. mark-paid: делегирование зачисления ───────────────────────────────────
test('head: mark-paid по issued-счёту → 200, счёт paid, баланс пополнен', function () {
Storage::fake('local');
Mail::fake();
$head = sinv_head();
[$tenant, $invoice] = sinv_scenario('100.00', '1500.00');
$response = $this->actingAs($head, 'sales')
->postJson("/api/sales/invoices/{$invoice->id}/mark-paid");
$response->assertOk()->assertJson(['status' => 'ok']);
$this->assertDatabaseHas('saas_invoices', ['id' => $invoice->id, 'status' => 'paid']);
$tenant->refresh();
expect((string) $tenant->balance_rub)->toBe('1600.00')
->and(SaasTransaction::where('invoice_id', $invoice->id)->where('status', 'success')->count())->toBe(1)
->and(SaasUpdDocument::where('invoice_id', $invoice->id)->count())->toBe(1);
});
test('head: повторный mark-paid идемпотентен — баланс не удваивается', function () {
Storage::fake('local');
Mail::fake();
$head = sinv_head();
[$tenant, $invoice] = sinv_scenario('0.00', '500.00');
$this->actingAs($head, 'sales')->postJson("/api/sales/invoices/{$invoice->id}/mark-paid")->assertOk();
$this->actingAs($head, 'sales')->postJson("/api/sales/invoices/{$invoice->id}/mark-paid")->assertOk();
$tenant->refresh();
expect((string) $tenant->balance_rub)->toBe('500.00')
->and(SaasUpdDocument::where('invoice_id', $invoice->id)->count())->toBe(1);
});