r->items()->update(['tax_rate' => $taxRate]);
foreach ($test->quotes as $index => $quote) {
$price = $firstPrice + $index * 10000;
$tax = intdiv($price * $taxRate + 50, 100);
$quote->items()->update(['unit_price_minor' => $price, 'tax_rate' => $taxRate, 'subtotal_minor' => $price, 'tax_minor' => $tax, 'total_minor' => $price + $tax]);
$quote->update(['subtotal_minor' => $price, 'tax_minor' => $tax, 'total_minor' => $price + $tax]);
}
}
function selectPurchaseAward(object $test): PurchaseAward
{
$test->actingAs($test->buyer)->post($test->awardUrl, $test->selection)->assertSessionHasNoErrors()->assertRedirect($test->awardUrl);
return PurchaseAward::query()->firstOrFail();
}
function approvePurchaseAward(object $test, PurchaseAward $award): void
{
foreach ($award->approvals()->get() as $step) {
$user = match ($step->role) {
'department_head' => $test->head, 'procurement' => $test->secondBuyer, 'finance' => $test->finance,
};
$test->actingAs($user)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors()->assertRedirect();
}
}
function issuePurchaseOrder(object $test, PurchaseAward $award): PurchaseOrder
{
approvePurchaseAward($test, $award);
$test->actingAs($test->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors()->assertRedirect();
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']);
$center = CostCenter::query()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'code' => 'PROD', 'name' => 'Produção']);
foreach (['owner' => PurchasingRole::Requester, 'buyer' => PurchasingRole::Procurement, 'secondBuyer' => PurchasingRole::Procurement, 'head' => PurchasingRole::DepartmentHead, 'finance' => PurchasingRole::Finance, 'admin' => PurchasingRole::Admin] as $key => $role) {
$this->{$key} = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => $role]);
}
$this->actingAs($this->buyer);
$this->r = Requisition::query()->create([
'tenant_id' => $this->tenant->id, 'requester_id' => $this->owner->id, 'department_id' => $this->department->id, 'cost_center_id' => $center->id,
'number' => 'REQ-ORDER', 'priority' => 'normal', 'needed_by' => now()->addMonth(), 'justification' => 'Necessidade de materiais para produção.',
'status' => 'approved', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000,
'approval_snapshot' => ['first_limit_minor' => 2500000, 'second_limit_minor' => 15000000, 'approval_includes_tax' => true, 'rules_provisional' => true],
]);
$this->r->steps()->create(['tenant_id' => $this->tenant->id, 'position' => 1, 'role' => 'department_head', 'decision' => 'approved', 'approver_id' => $this->head->id, 'decided_at' => now()]);
$this->item = $this->r->items()->create(['tenant_id' => $this->tenant->id, 'description' => 'Material de teste', 'category' => 'Material', 'purchase_type' => 'consumable', 'quantity' => '1', 'unit' => 'un', 'unit_price_minor' => 10000, 'tax_rate' => '0', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000]);
$this->quotes = [];
$this->suppliers = [];
foreach (['111222333', '222333444', '333444555'] as $index => $nuit) {
$supplier = Supplier::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'name' => 'Fornecedor '.$index, 'nuit' => $nuit, 'contact' => 'Contacto', 'email' => 'fornecedor'.$index.'@example.test', 'phone' => '123456789', 'address' => 'Maputo', 'category' => 'Material', 'beneficiaries' => 'Beneficiário', 'document_references' => 'Alvará conferido.', 'diligence_notes' => 'Documentação regular no teste.', 'status' => 'active', 'next_review' => now()->addYear()]);
$this->suppliers[] = $supplier;
$this->actingAs($this->buyer)->post('/requisicoes/'.$this->r->id.'/cotacoes', ['supplier_id' => $supplier->id, 'reference' => 'PROP-'.$index, 'delivery_days' => 5, 'valid_until' => now()->addMonth()->format('Y-m-d'), 'payment_terms' => '30 dias', 'no_conflict' => true, 'items' => [['requisition_item_id' => $this->item->id, 'unit_price' => (string) (100 + $index * 100)]]])->assertSessionHasNoErrors();
$quote = Quotation::query()->latest('id')->firstOrFail();
$this->quotes[] = $quote;
foreach ([$this->buyer, $this->finance] as $evaluator) {
$this->actingAs($evaluator)->post('/cotacoes/'.$quote->id.'/avaliar', ['quality' => '8', 'history' => '5', 'payment' => '8', 'comment' => 'Avaliação independente fundamentada.', 'no_conflict' => true])->assertSessionHasNoErrors();
}
}
$this->actingAs($this->buyer);
$this->awardUrl = '/requisicoes/'.$this->r->id.'/adjudicacao';
$this->selection = ['quotation_id' => $this->quotes[0]->id, 'justification' => 'Proposta com melhor pontuação técnica e comercial.', 'delivery_date' => now()->addWeek()->format('Y-m-d'), 'delivery_location' => 'Armazém central de Maputo'];
});
test('award starts a new approval cycle and order freezes all agreed data', function () {
$sourceSteps = $this->r->steps()->get()->toArray();
$award = selectPurchaseAward($this);
expect($award->status)->toBe('pending')->and($award->approvals()->count())->toBe(1);
expect($award->approvals()->firstOrFail()->decision)->toBe('pending');
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('approvals');
$order = issuePurchaseOrder($this, $award);
expect($order->number)->toMatch('/^OC-\d{4}-\d{6}$/')->and($order->total_minor)->toBe(10000);
expect($order->snapshot['supplier']['name'])->toBe('Fornecedor 0')->and($order->snapshot['quotation']['payment_terms'])->toBe('30 dias');
expect($order->snapshot['budget_checked'])->toBeFalse()->and($order->snapshot['development_only'])->toBeTrue();
expect($order->snapshot['award']['delivery_date'])->toBe($this->selection['delivery_date']);
expect($order->items()->count())->toBe(1)->and($order->snapshot['decision_matrix'])->toHaveCount(3);
expect($this->r->steps()->get()->toArray())->toBe($sourceSteps);
$this->get('/ordens/'.$order->id)->assertInertia(fn (Assert $page) => $page->component('purchases/show')->where('order.number', $order->number)->has('history', 3));
$this->get('/ordens')->assertInertia(fn (Assert $page) => $page->has('orders.data', 1)->has('awards.data', 1));
});
test('final approval tier uses inclusive boundaries of the stored process rules', function (int $priceMinor, int $count) {
awardPrices($this, $priceMinor);
$this->tenant->update(['first_limit_minor' => 1, 'second_limit_minor' => 2]);
$award = selectPurchaseAward($this);
expect($award->approvals()->count())->toBe($count);
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('approvals');
$order = issuePurchaseOrder($this, $award);
expect($order->total_minor)->toBe($priceMinor)->and($order->snapshot['approval_rules']['first_limit_minor'])->toBe(2500000);
})->with([[2500000, 1], [2500001, 2], [15000000, 2], [15000001, 3]]);
test('stored tax basis governs the final approval tier', function (bool $includesTax, int $count) {
awardPrices($this, 2500000, 16);
$rules = $this->r->approval_snapshot;
$rules['approval_includes_tax'] = $includesTax;
$this->r->update(['approval_snapshot' => $rules]);
$award = selectPurchaseAward($this);
expect($award->approvals()->count())->toBe($count)->and($award->tax_minor)->toBe(400000)->and($award->total_minor)->toBe(2900000);
})->with([[true, 2], [false, 1]]);
test('fractional quantities and VAT remain exact in issued lines and totals', function () {
$this->item->update(['quantity' => '1.125', 'tax_rate' => '16']);
foreach ([[123, 138, 22, 160], [223, 251, 40, 291], [323, 363, 58, 421]] as $index => [$unit, $subtotal, $tax, $total]) {
$q = $this->quotes[$index];
$q->items()->update(['quantity' => '1.125', 'tax_rate' => '16', 'unit_price_minor' => $unit, 'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $total]);
$q->update(['subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $total]);
}
$order = issuePurchaseOrder($this, selectPurchaseAward($this));
expect($order->subtotal_minor)->toBe(138)->and($order->tax_minor)->toBe(22)->and($order->total_minor)->toBe(160);
expect($order->items()->firstOrFail()->quantity)->toBe('1.125');
$this->get('/ordens/'.$order->id.'/imprimir')->assertOk()->assertSee('1,60 MZN')->assertSee('Não autoriza compras reais');
});
test('ties allow a justified choice between top scoring proposals', function () {
foreach ($this->quotes as $q) {
$q->items()->update(['unit_price_minor' => 10000, 'subtotal_minor' => 10000, 'total_minor' => 10000]);
$q->update(['subtotal_minor' => 10000, 'total_minor' => 10000]);
}
$this->selection['quotation_id'] = $this->quotes[2]->id;
$award = selectPurchaseAward($this);
expect($award->quotation_id)->toBe($this->quotes[2]->id);
});
test('non top selection and quote exemptions remain blocked without policy', function () {
$this->post($this->awardUrl, [...$this->selection, 'quotation_id' => $this->quotes[2]->id])->assertSessionHasErrors('quotation_id');
$this->post($this->awardUrl, [...$this->selection, 'override' => true])->assertSessionHasErrors('override');
$this->quotes[2]->update(['valid_until' => now()->subDay()]);
$this->post($this->awardUrl, $this->selection)->assertSessionHasErrors('comparison');
expect(PurchaseAward::query()->count())->toBe(0);
});
test('two currently eligible independent evaluators are required for every proposal', function (string $change) {
if ($change === 'role') {
$this->finance->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
} elseif ($change === 'absence') {
$this->quotes[0]->evaluations()->where('evaluator_id', $this->finance->id)->update(['no_conflict' => false]);
} elseif ($change === 'requester') {
$this->quotes[0]->evaluations()->where('evaluator_id', $this->finance->id)->update(['evaluator_id' => $this->owner->id]);
$this->owner->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
} else {
$this->quotes[0]->evaluations()->where('evaluator_id', $this->finance->id)->delete();
}
$this->post($this->awardUrl, $this->selection)->assertSessionHasErrors('comparison');
})->with(['role', 'absence', 'requester', 'missing']);
test('invalid source policy approval or unsupported purchase type cannot be presumed valid', function (string $change, string $error) {
if ($change === 'snapshot') {
$this->r->update(['approval_snapshot' => null]);
} elseif ($change === 'approval') {
$this->r->steps()->update(['decision' => 'pending']);
} else {
$this->item->update(['purchase_type' => $change]);
}
$this->post($this->awardUrl, $this->selection)->assertSessionHasErrors($error);
expect(PurchaseAward::query()->count())->toBe(0);
})->with([['snapshot', 'policy'], ['approval', 'approvals'], ['equipment', 'purchase_type'], ['spare_part', 'purchase_type']]);
test('award approvals enforce role order department distinct people and no self approval', function () {
awardPrices($this, 15000001);
$award = selectPurchaseAward($this);
$this->actingAs($this->finance)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
$this->actingAs($this->admin)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
$otherDepartment = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Outro']);
$otherHead = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $otherDepartment->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
$this->actingAs($otherHead)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
$this->actingAs($this->head)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
$this->head->forceFill(['purchasing_role' => PurchasingRole::Procurement])->save();
$this->actingAs($this->head)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
$this->actingAs($this->secondBuyer)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
$this->actingAs($this->finance)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('approvals');
});
test('rejection requires a reason and prevents issuance', function () {
$award = selectPurchaseAward($this);
$this->actingAs($this->head)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'rejected'])->assertSessionHasErrors('comment');
$this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'rejected', 'comment' => 'Necessário rever a necessidade e a proposta.'])->assertSessionHasNoErrors();
expect($award->refresh()->status)->toBe('rejected');
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('approvals');
});
test('issuer revalidates suspension expiry conflicts and evaluator eligibility after approval', function (string $change) {
$award = selectPurchaseAward($this);
approvePurchaseAward($this, $award);
if ($change === 'suspension') {
$this->suppliers[0]->update(['status' => 'suspended']);
} elseif ($change === 'expiry') {
$this->quotes[0]->update(['valid_until' => now()->subDay()]);
} elseif ($change === 'role') {
$this->finance->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
} else {
$user = $change === 'conflict_evaluator' ? $this->finance : $this->head;
$this->actingAs($user)->post('/conflitos', ['scope' => 'supplier', 'supplier_id' => $this->suppliers[0]->id, 'has_conflict' => true, 'known_since' => now()->format('Y-m-d'), 'reason' => 'Conflito conhecido após a avaliação e aprovação.'])->assertSessionHasNoErrors();
}
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors();
expect(PurchaseOrder::query()->count())->toBe(0);
})->with(['suspension', 'expiry', 'role', 'conflict_evaluator', 'conflict_approver']);
test('changed prices or matrix after award cannot reuse approvals', function () {
$award = selectPurchaseAward($this);
approvePurchaseAward($this, $award);
awardPrices($this, 10001);
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('comparison');
expect(PurchaseOrder::query()->count())->toBe(0);
});
test('invalid line totals are rejected instead of trusting stored totals', function () {
$this->quotes[0]->items()->update(['tax_minor' => 99]);
$this->post($this->awardUrl, $this->selection)->assertSessionHasErrors('comparison');
});
test('repeated issuance returns the same order and does not duplicate audit or lines', function () {
$award = selectPurchaseAward($this);
$order = issuePurchaseOrder($this, $award);
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertRedirect('/ordens/'.$order->id);
expect(PurchaseOrder::query()->count())->toBe(1)->and($order->items()->count())->toBe(1);
expect(AuditLog::query()->where('action', 'purchase_order.issued')->count())->toBe(1);
$this->post($this->awardUrl, $this->selection)->assertSessionHasErrors('award');
});
test('database uniqueness prevents a second document for the same process', function () {
$order = issuePurchaseOrder($this, selectPurchaseAward($this));
$duplicate = $order->getAttributes();
unset($duplicate['id']);
$duplicate['number'] = 'OC-DUPLICATE';
expect(fn () => DB::table('purchase_orders')->insert($duplicate))->toThrow(QueryException::class);
});
test('changes after issuance cannot rewrite supplier terms evaluations or printed content', function () {
$award = selectPurchaseAward($this);
$order = issuePurchaseOrder($this, $award);
$snapshot = $order->snapshot;
$this->suppliers[0]->update(['name' => 'Nome posterior', 'address' => 'Morada posterior']);
$this->quotes[0]->update(['payment_terms' => 'Pagamento posterior']);
$this->quotes[0]->evaluations()->update(['quality' => 1]);
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertRedirect('/ordens/'.$order->id);
expect($order->refresh()->snapshot)->toBe($snapshot);
$this->get('/ordens/'.$order->id.'/imprimir')->assertOk()->assertSee('Fornecedor 0')->assertDontSee('Nome posterior')->assertDontSee('Pagamento posterior');
expect(fn () => $order->update(['total_minor' => 1]))->toThrow(LogicException::class);
});
test('new quotations and evaluations cannot change a selected comparison', function () {
$award = selectPurchaseAward($this);
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', ['supplier_id' => $this->suppliers[0]->id, 'reference' => 'Nova', 'delivery_days' => 1, 'valid_until' => now()->addWeek()->format('Y-m-d'), 'payment_terms' => '30 dias', 'no_conflict' => true, 'items' => [['requisition_item_id' => $this->item->id, 'unit_price' => '1']]])->assertSessionHasErrors('award');
$this->actingAs($this->secondBuyer)->post('/cotacoes/'.$this->quotes[0]->id.'/avaliar', ['quality' => '8', 'history' => '5', 'payment' => '8', 'comment' => 'Nova avaliação depois da selecção.', 'no_conflict' => true])->assertSessionHasErrors('award');
expect($award->approvals()->firstOrFail()->decision)->toBe('pending');
});
test('browser cannot set award or order money status or override rules', function () {
$this->post($this->awardUrl, [...$this->selection, 'total_minor' => 1, 'status' => 'approved'])->assertSessionHasErrors(['total_minor', 'status']);
$award = selectPurchaseAward($this);
$this->post('/adjudicacoes/'.$award->id.'/emitir', ['total_minor' => 1, 'status' => 'paid', 'override' => true])->assertSessionHasErrors(['total_minor', 'status', 'override']);
});
test('award and order operations restrict profiles and departments', function (PurchasingRole $role) {
$award = selectPurchaseAward($this);
$user = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => $role]);
$this->actingAs($user)->post($this->awardUrl, $this->selection)->assertForbidden();
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertForbidden();
if (in_array($role, [PurchasingRole::Requester, PurchasingRole::Warehouse])) {
$this->get('/ordens')->assertForbidden();
$this->get($this->awardUrl)->assertForbidden();
}
})->with([PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::DepartmentHead, PurchasingRole::Requester, PurchasingRole::Warehouse]);
test('other companies cannot select approve issue list read or print documents', function () {
$award = selectPurchaseAward($this);
$order = issuePurchaseOrder($this, $award);
$tenant = Tenant::query()->create(['name' => 'Outra empresa']);
$user = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Procurement]);
$this->actingAs($user)->get('/ordens')->assertInertia(fn (Assert $page) => $page->has('orders.data', 0)->has('awards.data', 0));
$this->get($this->awardUrl)->assertNotFound();
$this->post($this->awardUrl, $this->selection)->assertNotFound();
$this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertNotFound();
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertNotFound();
$this->get('/ordens/'.$order->id)->assertNotFound();
$this->get('/ordens/'.$order->id.'/imprimir')->assertNotFound();
});
test('audit failure rolls back issuance including document lines and number', function () {
$award = selectPurchaseAward($this);
approvePurchaseAward($this, $award);
$this->mock(RequisitionWorkflow::class, function ($mock) {
$mock->shouldReceive('audit')->once()->andThrow(new RuntimeException('Falha de auditoria da emissão.'));
});
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertStatus(500);
expect(PurchaseOrder::query()->count())->toBe(0);
$this->assertDatabaseCount('purchase_order_items', 0);
expect($award->refresh()->status)->toBe('approved');
});
test('audit failure rolls back award and its approval cycle', function () {
$this->mock(RequisitionWorkflow::class, function ($mock) {
$mock->shouldReceive('audit')->once()->andThrow(new RuntimeException('Falha de auditoria da adjudicação.'));
});
$this->post($this->awardUrl, $this->selection)->assertStatus(500);
expect(PurchaseAward::query()->count())->toBe(0);
$this->assertDatabaseCount('purchase_award_approvals', 0);
});
test('supplier names are frozen at issuance rather than read from live records', function () {
$award = selectPurchaseAward($this);
approvePurchaseAward($this, $award);
$this->suppliers[0]->update(['name' => 'Nome validado antes de emitir']);
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors();
$order = PurchaseOrder::query()->firstOrFail();
expect($order->snapshot['supplier']['name'])->toBe('Nome validado antes de emitir');
expect($order->snapshot['decision_matrix'][0]['supplier']['name'])->toBe('Nome validado antes de emitir');
});
test('print escapes supplied HTML and keeps the development and budget notices', function () {
$this->selection['justification'] = 'Fundamento escrito para teste de impressão.';
$order = issuePurchaseOrder($this, selectPurchaseAward($this));
$this->get('/ordens/'.$order->id.'/imprimir')->assertOk()->assertSee('<script>', false)->assertDontSee('', false)->assertSee('Orçamento não verificado')->assertSee('Não autoriza compras reais');
});
test('department heads cannot read or print another departments order', function () {
$order = issuePurchaseOrder($this, selectPurchaseAward($this));
$other = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Departamento distinto']);
$head = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $other->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
$this->actingAs($head)->get('/ordens')->assertInertia(fn (Assert $page) => $page->has('orders.data', 0)->has('awards.data', 0));
$this->get('/ordens/'.$order->id)->assertNotFound();
$this->get('/ordens/'.$order->id.'/imprimir')->assertNotFound();
$this->get($this->awardUrl)->assertNotFound();
});
test('quotation from a different process cannot be selected', function () {
$other = $this->r->replicate();
$other->number = 'REQ-OTHER';
$other->save();
$q = $this->quotes[0]->replicate();
$q->requisition_id = $other->id;
$q->save();
$this->post($this->awardUrl, [...$this->selection, 'quotation_id' => $q->id])->assertNotFound();
});
test('a conflict recorded after selection blocks the remaining approvals', function () {
$award = selectPurchaseAward($this);
$this->actingAs($this->head)->post('/conflitos', ['scope' => 'process', 'requisition_id' => $this->r->id, 'has_conflict' => true, 'known_since' => now()->format('Y-m-d'), 'reason' => 'Conflito identificado antes da decisão final.'])->assertSessionHasNoErrors();
$this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
expect($award->approvals()->firstOrFail()->decision)->toBe('pending');
});
test('delivery dates that expire before emission are not silently replaced', function () {
$this->selection['delivery_date'] = now()->format('Y-m-d');
$award = selectPurchaseAward($this);
approvePurchaseAward($this, $award);
$this->travel(1)->days();
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('delivery_date');
expect(PurchaseOrder::query()->count())->toBe(0);
});
test('spare purchase flows through award order receipt and issue with equipment trace', function () {
$level = CriticalityLevel::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->admin->id, 'code' => 'PART-TEST', 'name' => 'Normal de teste', 'criteria' => 'Critério explícito da fixture', 'is_critical' => false, 'created_at' => now()]);
$asset = Equipment::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'department_id' => $this->department->id, 'criticality_level_id' => $level->id, 'code' => 'EQ-FLOW', 'description' => 'Máquina da fixture', 'area' => 'Produção', 'status' => 'active']);
$stock = StockItem::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'code' => 'PART-FLOW', 'name' => 'Peça da fixture', 'unit' => 'un']);
$part = SparePart::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'stock_item_id' => $stock->id, 'criticality_level_id' => $level->id, 'minimum_milli' => 500]);
EquipmentSparePart::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'equipment_id' => $asset->id, 'spare_part_id' => $part->id, 'created_at' => now()]);
$this->item->update(['purchase_type' => 'spare_part', 'spare_part_id' => $part->id, 'equipment_id' => $asset->id]);
$award = selectPurchaseAward($this);
$order = issuePurchaseOrder($this, $award);
expect($order->items()->firstOrFail()->spare_part_id)->toBe($part->id)->and($order->snapshot['equipment_context'][0]['equipment']['description'])->toBe('Máquina da fixture');
$asset->update(['description' => 'Nome actualizado']);
expect($order->refresh()->snapshot['equipment_context'][0]['equipment']['description'])->toBe('Máquina da fixture');
$receiver = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Warehouse]);
$this->actingAs($receiver);
$warehouse = Warehouse::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $receiver->id, 'code' => 'FLOW', 'name' => 'Armazém de teste']);
$receipt = ['idempotency_key' => (string) Str::uuid(), 'delivery_note' => 'GR-FLOW', '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]]];
$this->postJson('/ordens/'.$order->id.'/recepcoes', $receipt)->assertCreated();
$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']);
test('purchase issuance rechecks a revoked procurement role inside transaction', function () {
$award = selectPurchaseAward($this);
approvePurchaseAward($this, $award);
$this->actingAs($this->buyer);
DB::table('users')->where('id', $this->buyer->id)->update(['purchasing_role' => 'warehouse']);
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertForbidden();
expect(PurchaseOrder::query()->count())->toBe(0);
});
test('final approval rechecks a department head who moved departments', function () {
$award = selectPurchaseAward($this);
$department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Outro departamento fictício']);
$this->actingAs($this->head);
DB::table('users')->where('id', $this->head->id)->update(['department_id' => $department->id]);
$this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
expect($award->approvals()->where('decision', 'approved')->count())->toBe(0);
});