163 lines
8.3 KiB
PHP
163 lines
8.3 KiB
PHP
<?php
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Models\Budget;
|
|
use App\Models\BudgetEntry;
|
|
use App\Models\User;
|
|
use App\Services\BudgetWorkflow;
|
|
use App\Services\ConflictGuard;
|
|
use App\Services\RequisitionWorkflow;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
|
|
|
beforeEach(function () {
|
|
$this->f = receiptFixture();
|
|
$f = $this->f;
|
|
$this->author = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
|
$this->approver = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
|
|
$this->actingAs($this->author);
|
|
$this->budget = Budget::query()->create(['tenant_id' => $f['tenant']->id, 'cost_center_id' => $f['r']->cost_center_id, 'year' => now()->year, 'amount_minor' => 20000, 'includes_tax' => true, 'reason' => 'Dotação fictícia', 'created_by' => $this->author->id]);
|
|
});
|
|
|
|
function budgetRecord(object $test, string $kind, int $amount = 0, ?int $subtotal = null): mixed
|
|
{
|
|
return DB::transaction(function () use ($test, $kind, $amount, $subtotal) {
|
|
app(ConflictGuard::class)->lockCompany($test->author);
|
|
|
|
return app(BudgetWorkflow::class)->record($test->author, $test->f['r'], $kind, $amount, $subtotal);
|
|
});
|
|
}
|
|
|
|
test('budget requires independent approval and records only once', function () {
|
|
$this->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertForbidden();
|
|
$this->actingAs($this->approver)->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertRedirect();
|
|
$this->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertRedirect();
|
|
expect($this->budget->refresh()->approved_by)->toBe($this->approver->id);
|
|
});
|
|
|
|
test('pending allocation blocks reservation', function () {
|
|
expect(fn () => budgetRecord($this, 'reserve', 10000))->toThrow(ValidationException::class);
|
|
expect(BudgetEntry::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('reserve award and commitment do not count spending twice', function () {
|
|
$this->budget->update(['approved_by' => $this->approver->id]);
|
|
budgetRecord($this, 'reserve', 10000);
|
|
budgetRecord($this, 'reserve', 10000);
|
|
budgetRecord($this, 'award', 15000);
|
|
budgetRecord($this, 'commit', 15000);
|
|
budgetRecord($this, 'commit', 15000);
|
|
expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(0)
|
|
->and((int) BudgetEntry::query()->sum('committed_minor'))->toBe(15000)
|
|
->and(BudgetEntry::query()->count())->toBe(3);
|
|
});
|
|
|
|
test('over budget adjustments roll back without losing reservation', function () {
|
|
$this->budget->update(['approved_by' => $this->approver->id]);
|
|
budgetRecord($this, 'reserve', 10000);
|
|
expect(fn () => budgetRecord($this, 'award', 20001))->toThrow(ValidationException::class);
|
|
expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(10000);
|
|
});
|
|
|
|
test('rejection releases reservation once and history is immutable', function () {
|
|
$this->budget->update(['approved_by' => $this->approver->id]);
|
|
budgetRecord($this, 'reserve', 10000);
|
|
budgetRecord($this, 'release');
|
|
budgetRecord($this, 'release');
|
|
expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(0);
|
|
expect(fn () => BudgetEntry::query()->firstOrFail()->delete())->toThrow(LogicException::class);
|
|
});
|
|
|
|
test('company chooses its own tax basis', function () {
|
|
$this->budget->update(['approved_by' => $this->approver->id, 'includes_tax' => false]);
|
|
budgetRecord($this, 'reserve', 11600, 10000);
|
|
expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(10000);
|
|
});
|
|
|
|
test('approved company allocation and tax basis cannot be rewritten', function () {
|
|
$this->budget->update(['approved_by' => $this->approver->id]);
|
|
expect(fn () => $this->budget->update(['amount_minor' => 999999]))->toThrow(LogicException::class);
|
|
expect(fn () => $this->budget->refresh()->update(['includes_tax' => false]))->toThrow(LogicException::class);
|
|
expect(fn () => $this->budget->refresh()->delete())->toThrow(LogicException::class);
|
|
});
|
|
|
|
test('foreign company cannot read or approve allocation', function () {
|
|
$other = receiptFixture();
|
|
$user = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
|
$this->actingAs($user)->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertNotFound();
|
|
expect(Budget::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('warehouse cannot prepare or consult budgets', function () {
|
|
$this->actingAs($this->f['receiver'])->get('/orcamentos')->assertForbidden();
|
|
$this->post('/orcamentos', [])->assertForbidden();
|
|
});
|
|
|
|
test('allocation amounts and tax basis are mandatory company decisions', function () {
|
|
$this->budget->delete();
|
|
$data = ['cost_center_id' => $this->f['r']->cost_center_id, 'year' => now()->year, 'amount' => '123.45', 'reason' => 'Decisão fictícia'];
|
|
$this->postJson('/orcamentos', $data)->assertUnprocessable()->assertJsonValidationErrors('includes_tax');
|
|
$this->post('/orcamentos', [...$data, 'includes_tax' => false])->assertRedirect()->assertSessionHasNoErrors();
|
|
expect(Budget::query()->firstOrFail()->amount_minor)->toBe(12345)
|
|
->and(Budget::query()->firstOrFail()->includes_tax)->toBeFalse();
|
|
});
|
|
|
|
test('failed audit rolls back budget reservation', function () {
|
|
$this->budget->update(['approved_by' => $this->approver->id]);
|
|
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('audit unavailable'));
|
|
expect(fn () => budgetRecord($this, 'reserve', 10000))->toThrow(RuntimeException::class);
|
|
expect(BudgetEntry::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('submission with insufficient approved budget leaves draft and approvals unchanged', function () {
|
|
$this->budget->update(['approved_by' => $this->approver->id, 'amount_minor' => 9999]);
|
|
$request = $this->f['r'];
|
|
$request->update(['status' => 'draft']);
|
|
$this->actingAs($this->f['issuer']);
|
|
expect(fn () => app(RequisitionWorkflow::class)->submit($this->f['issuer'], $request))->toThrow(ValidationException::class);
|
|
expect($request->refresh()->status)->toBe('draft')
|
|
->and($request->steps()->count())->toBe(0)
|
|
->and(BudgetEntry::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('submission reserves approved allocation and rejection releases it', function () {
|
|
$this->budget->update(['approved_by' => $this->approver->id]);
|
|
$request = $this->f['r'];
|
|
$request->update(['status' => 'draft']);
|
|
$this->actingAs($this->f['issuer']);
|
|
app(RequisitionWorkflow::class)->submit($this->f['issuer'], $request);
|
|
expect($request->refresh()->status)->toBe('pending')
|
|
->and((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(10000);
|
|
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
|
$this->actingAs($head);
|
|
app(RequisitionWorkflow::class)->decide($head, $request, 'rejected', 'Necessidade não confirmada.');
|
|
expect($request->refresh()->status)->toBe('rejected')
|
|
->and((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(0);
|
|
});
|
|
|
|
test('budget service refuses a requisition belonging to another company', function () {
|
|
$other = receiptFixture();
|
|
$this->actingAs($this->author);
|
|
try {
|
|
DB::transaction(fn () => app(BudgetWorkflow::class)->record($this->author, $other['r'], 'reserve', 10000));
|
|
$this->fail('A foreign requisition must be rejected.');
|
|
} catch (HttpException $exception) {
|
|
expect($exception->getStatusCode())->toBe(403);
|
|
}
|
|
expect(BudgetEntry::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('unapproved allocation displays no authorised or available funds', function () {
|
|
$this->get('/orcamentos')->assertOk()->assertInertia(fn ($page) => $page->where('budgets.0.amount_minor', 20000)->where('budgets.0.approved_total_minor', 0)->where('budgets.0.available_minor', 0));
|
|
});
|
|
|
|
test('initial allocation approval rechecks administrator role inside transaction', function () {
|
|
$this->actingAs($this->approver);
|
|
DB::table('users')->where('id', $this->approver->id)->update(['purchasing_role' => 'warehouse']);
|
|
$this->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertForbidden();
|
|
expect($this->budget->refresh()->approved_by)->toBeNull();
|
|
});
|