180 lines
7.7 KiB
PHP
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);
|
||
|
|
});
|