Implementa regras de investimento por empresa com dupla aprovacao

This commit is contained in:
2026-10-28 16:57:45 +02:00
parent 153ba2c1af
commit 95dd2cc1ed
37 changed files with 1418 additions and 28 deletions
+84
View File
@@ -2,11 +2,13 @@
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\Budget;
use App\Models\CostCenter;
use App\Models\CriticalityLevel;
use App\Models\Department;
use App\Models\Equipment;
use App\Models\EquipmentSparePart;
use App\Models\InvestmentPolicy;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\Quotation;
@@ -18,6 +20,9 @@ use App\Models\Supplier;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\BudgetWorkflow;
use App\Services\ConflictGuard;
use App\Services\EquipmentWorkflow;
use App\Services\RequisitionWorkflow;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
@@ -60,6 +65,27 @@ function issuePurchaseOrder(object $test, PurchaseAward $award): PurchaseOrder
return PurchaseOrder::query()->firstOrFail();
}
function prepareEquipmentInvestment(object $test, string $ceiling = '100.00', bool $includesTax = true): InvestmentPolicy
{
require_once __DIR__.'/../Support/InvestmentFixture.php';
$test->item->update(['purchase_type' => 'equipment', 'production_area' => 'Linha fictícia de investimento']);
$test->r->update(['status' => 'draft']);
$test->r->refresh();
$test->actingAs($test->head);
$technical = app(EquipmentWorkflow::class);
$technical->review($test->head, $test->r, $technical->basis($test->r), 'approved', 'Validação técnica fictícia independente.');
$test->r->update(['status' => 'approved']);
$test->r->steps()->create(['tenant_id' => $test->tenant->id, 'position' => 2, 'role' => 'finance', 'decision' => 'approved', 'approver_id' => $test->finance->id, 'decided_at' => now()]);
$test->actingAs($test->owner);
Budget::query()->create(['tenant_id' => $test->tenant->id, 'cost_center_id' => $test->r->cost_center_id, 'year' => now()->year, 'kind' => 'investment', 'amount_minor' => 100000, 'includes_tax' => true, 'created_by' => $test->admin->id, 'approved_by' => $test->finance->id, 'reason' => 'Dotação fictícia de investimento']);
DB::transaction(function () use ($test) {
app(ConflictGuard::class)->lockCompany($test->owner);
app(BudgetWorkflow::class)->record($test->owner, $test->r, 'reserve', $test->r->total_minor, $test->r->subtotal_minor);
});
return publishInvestmentFixture($test->tenant->id, $ceiling, $includesTax);
}
beforeEach(function () {
$this->tenant = Tenant::query()->create(['name' => 'Empresa das ordens']);
$this->department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Produção']);
@@ -402,3 +428,61 @@ test('spare purchase flows through award order receipt and issue with equipment
$this->postJson('/stock/operacoes', ['kind' => 'issue', 'reference' => 'SAI-FLOW', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $warehouse->id, 'stock_item_id' => $stock->id, 'responsible_id' => $receiver->id, 'destination' => 'Produção', 'reason' => 'Montagem no equipamento', 'quantity' => '0.500', 'equipment_id' => $asset->id])->assertCreated();
expect((int) StockMovement::query()->where('stock_item_id', $stock->id)->sum('quantity_milli'))->toBe(500)->and(StockMovement::query()->where('equipment_id', $asset->id)->count())->toBe(2);
});
test('equipment within the explicit inclusive investment ceiling requires finance and flows through receipt', function () {
$policy = prepareEquipmentInvestment($this);
$award = selectPurchaseAward($this);
expect($award->approvals()->pluck('role')->all())->toBe(['department_head', 'finance']);
$order = issuePurchaseOrder($this, $award);
expect($order->snapshot['investment_rules']['id'])->toBe($policy->id)
->and($order->snapshot['investment_rules']['ceiling_minor'])->toBe(10000)
->and($order->snapshot['equipment_requests'][0]['production_area'])->toBe('Linha fictícia de investimento');
$receiver = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Warehouse]);
$this->actingAs($receiver);
$warehouse = Warehouse::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $receiver->id, 'code' => 'INV', 'name' => 'Armazém fictício']);
$stock = StockItem::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $receiver->id, 'code' => 'EQ-INV', 'name' => 'Equipamento recebido fictício', 'unit' => 'un']);
$this->postJson('/ordens/'.$order->id.'/recepcoes', ['idempotency_key' => (string) Str::uuid(), 'delivery_note' => 'GR-INV', 'received_on' => today()->format('Y-m-d'), 'warehouse_id' => $warehouse->id, 'items' => [['purchase_order_item_id' => $order->items()->firstOrFail()->id, 'received_quantity' => '1.000', 'accepted_quantity' => '1.000', 'quality_status' => 'compliant', 'stock_item_id' => $stock->id]]])->assertCreated();
expect((int) StockMovement::query()->where('stock_item_id', $stock->id)->sum('quantity_milli'))->toBe(1000);
publishInvestmentFixture($this->tenant->id, '200.00');
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors();
expect(PurchaseOrder::query()->count())->toBe(1)->and($order->refresh()->snapshot['investment_rules']['ceiling_minor'])->toBe(10000);
});
test('equipment above the company investment ceiling cannot be awarded', function () {
prepareEquipmentInvestment($this, '99.99');
$this->actingAs($this->buyer)->post($this->awardUrl, $this->selection)->assertSessionHasErrors('investment');
expect(PurchaseAward::query()->count())->toBe(0);
});
test('investment tax basis is explicit and independent of ordinary approval tiers', function (bool $includesTax) {
awardPrices($this, 10000, 16);
prepareEquipmentInvestment($this, '100.00', $includesTax);
if ($includesTax) {
$this->actingAs($this->buyer)->post($this->awardUrl, $this->selection)->assertSessionHasErrors('investment');
} else {
$order = issuePurchaseOrder($this, selectPurchaseAward($this));
expect($order->total_minor)->toBe(11600)->and($order->snapshot['investment_rules']['includes_tax'])->toBeFalse();
}
})->with([true, false]);
test('a newly published investment policy cannot reuse an existing final approval cycle', function () {
prepareEquipmentInvestment($this);
$award = selectPurchaseAward($this);
approvePurchaseAward($this, $award);
publishInvestmentFixture($this->tenant->id, '200.00');
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('investment');
expect(PurchaseOrder::query()->count())->toBe(0);
});
test('equipment cannot bypass technical finance or investment budget requirements', function (string $missing) {
prepareEquipmentInvestment($this);
if ($missing === 'technical') {
DB::table('technical_reviews')->where('requisition_id', $this->r->id)->delete();
} elseif ($missing === 'finance') {
$this->r->steps()->where('role', 'finance')->delete();
} else {
DB::table('budgets')->where('tenant_id', $this->tenant->id)->update(['kind' => 'operating']);
}
$this->actingAs($this->buyer)->post($this->awardUrl, $this->selection)->assertSessionHasErrors($missing === 'technical' ? 'technical' : ($missing === 'finance' ? 'approvals' : 'investment'));
expect(PurchaseAward::query()->count())->toBe(0);
})->with(['technical', 'finance', 'budget']);