'Fornecedor de teste', 'nuit' => $nuit, 'contact' => 'Contacto', 'email' => 'test@example.com', 'phone' => '123456789', 'address' => 'Maputo', 'category' => 'Material', 'beneficiaries' => 'Beneficiário teste', 'document_references' => 'Alvará e declaração verificados no processo 123.', 'diligence_notes' => 'Documentação conferida e sem impedimentos.', 'blocked' => false, 'next_review' => now()->addYear()->format('Y-m-d')]; } beforeEach(function () { $this->tenant = Tenant::query()->create(['name' => 'Cotação A']); $this->department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Produção']); $this->center = CostCenter::query()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'name' => 'Produção', 'code' => 'P']); $this->buyer = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Procurement]); $this->finance = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Finance]); $this->owner = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Requester]); $this->actingAs($this->buyer); $this->supplier = Supplier::query()->create([...supplierPayload(), 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'status' => 'active']); $this->r = Requisition::query()->create(['tenant_id' => $this->tenant->id, 'requester_id' => $this->owner->id, 'department_id' => $this->department->id, 'cost_center_id' => $this->center->id, 'number' => 'REQ-TEST', 'priority' => 'normal', 'needed_by' => now()->addWeek(), 'justification' => 'Pedido aprovado para testes.', 'status' => 'approved', 'subtotal_minor' => 15, 'tax_minor' => 2, 'total_minor' => 17]); $this->item = $this->r->items()->create(['tenant_id' => $this->tenant->id, 'description' => 'Material', 'category' => 'Material', 'purchase_type' => 'consumable', 'quantity' => '3', 'unit' => 'un', 'unit_price_minor' => 5, 'tax_rate' => '16', 'subtotal_minor' => 15, 'tax_minor' => 2, 'total_minor' => 17]); $this->quote = ['supplier_id' => $this->supplier->id, 'reference' => 'PROP-01', 'delivery_days' => 10, 'valid_until' => now()->addMonth()->format('Y-m-d'), 'payment_terms' => '30 dias', 'no_conflict' => true, 'items' => [['requisition_item_id' => $this->item->id, 'unit_price' => '0.05']]]; }); test('supplier creation enforces role tenant and unique nuit', function () { $this->actingAs($this->owner)->post('/fornecedores', supplierPayload('987654321'))->assertForbidden(); $this->actingAs($this->buyer)->post('/fornecedores', [...supplierPayload('987654321'), 'status' => 'active', 'tenant_id' => 999])->assertSessionHasNoErrors()->assertRedirect(); $s = Supplier::query()->where('nuit', '987654321')->firstOrFail(); expect($s->status)->toBe('pending')->and($s->tenant_id)->toBe($this->tenant->id); $this->post('/fornecedores', supplierPayload('987654321'))->assertSessionHasErrors('nuit'); $this->post('/fornecedores', supplierPayload('invalid'))->assertSessionHasErrors('nuit'); }); test('supplier review is independent and prevents blocked activation', function () { $body = ['status' => 'active', 'comment' => 'Documentação verificada por Finanças.', 'version' => $this->supplier->version]; $this->post("/fornecedores/{$this->supplier->id}/validar", $body)->assertForbidden(); $this->buyer->forceFill(['purchasing_role' => PurchasingRole::Finance])->save(); $this->actingAs($this->buyer)->post("/fornecedores/{$this->supplier->id}/validar", $body)->assertForbidden(); $this->actingAs($this->finance)->post("/fornecedores/{$this->supplier->id}/validar", $body)->assertSessionHasNoErrors()->assertRedirect(); $this->supplier->update(['blocked' => true]); $this->post("/fornecedores/{$this->supplier->id}/validar", [...$body, 'version' => $this->supplier->fresh()->version])->assertSessionHasErrors('status'); }); test('quotes calculate decimal totals inherit tax and reject duplicates', function () { $this->post("/requisicoes/{$this->r->id}/cotacoes", [...$this->quote, 'total_minor' => 999, 'tenant_id' => 999])->assertSessionHasNoErrors()->assertRedirect(); $q = Quotation::query()->firstOrFail(); expect($q->subtotal_minor)->toBe(15)->and($q->tax_minor)->toBe(2)->and($q->total_minor)->toBe(17)->and($q->tenant_id)->toBe($this->tenant->id); expect((float) $q->items()->first()->tax_rate)->toBe(16.0); $this->post("/requisicoes/{$this->r->id}/cotacoes", $this->quote)->assertSessionHasErrors('supplier_id'); expect(Quotation::query()->count())->toBe(1); expect(AuditLog::query()->where('action', 'quotation.created')->count())->toBe(1); }); test('quotes require approval active suppliers valid dates and exact request items', function () { $url = "/requisicoes/{$this->r->id}/cotacoes"; $this->r->update(['status' => 'draft']); $this->post($url, $this->quote)->assertForbidden(); $this->r->update(['status' => 'approved']); $this->supplier->update(['status' => 'pending']); $this->post($url, $this->quote)->assertSessionHasErrors('supplier_id'); $this->supplier->update(['status' => 'active']); $this->post($url, [...$this->quote, 'valid_until' => now()->subDay()->format('Y-m-d')])->assertSessionHasErrors('valid_until'); $this->post($url, [...$this->quote, 'items' => [['requisition_item_id' => 999, 'unit_price' => '1']]])->assertSessionHasErrors('items'); $this->post($url, [...$this->quote, 'no_conflict' => false])->assertSessionHasErrors('no_conflict'); expect(Quotation::query()->count())->toBe(0); }); test('two independent evaluations and three eligible suppliers complete comparison', function () { foreach (['123456789', '123456788', '123456787'] as $index => $nuit) { $s = $index === 0 ? $this->supplier : Supplier::query()->create([...supplierPayload($nuit), 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'status' => 'active']); $this->actingAs($this->buyer)->post("/requisicoes/{$this->r->id}/cotacoes", [...$this->quote, 'supplier_id' => $s->id])->assertSessionHasNoErrors(); $q = Quotation::query()->latest('id')->firstOrFail(); $ev = ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'Avaliação técnica independente.', 'no_conflict' => true]; $this->post("/cotacoes/{$q->id}/avaliar", $ev)->assertSessionHasNoErrors(); $this->post("/cotacoes/{$q->id}/avaliar", $ev)->assertSessionHasErrors('evaluation'); $this->actingAs($this->finance)->post("/cotacoes/{$q->id}/avaliar", $ev)->assertSessionHasNoErrors(); } $this->get("/requisicoes/{$this->r->id}/cotacoes")->assertInertia(fn (Assert $page) => $page->component('quotations/show')->where('ready', true)->has('quotations', 3)->where('quotations.0.score', 8.65)); $this->supplier->update(['status' => 'suspended']); $this->get("/requisicoes/{$this->r->id}/cotacoes")->assertInertia(fn (Assert $page) => $page->where('ready', false)->where('quotations.0.eligible', false)); }); test('requester cannot evaluate own purchase after role change', function () { $this->post("/requisicoes/{$this->r->id}/cotacoes", $this->quote); $q = Quotation::query()->firstOrFail(); $this->owner->forceFill(['purchasing_role' => PurchasingRole::Finance])->save(); $this->actingAs($this->owner)->post("/cotacoes/{$q->id}/avaliar", ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'Avaliação de pedido próprio.', 'no_conflict' => true])->assertForbidden(); }); test('supplier quote and evaluation routes isolate companies', function () { $this->post("/requisicoes/{$this->r->id}/cotacoes", $this->quote); $q = Quotation::query()->firstOrFail(); $tenant = Tenant::query()->create(['name' => 'Empresa B']); $other = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Finance]); $this->actingAs($other)->get('/fornecedores')->assertInertia(fn (Assert $p) => $p->has('suppliers.data', 0)); $this->post("/fornecedores/{$this->supplier->id}/validar", ['status' => 'active', 'comment' => 'Tentativa de outro inquilino.'])->assertNotFound(); $this->get("/requisicoes/{$this->r->id}/cotacoes")->assertNotFound(); $this->post("/cotacoes/{$q->id}/avaliar", ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'Tentativa de outro inquilino.', 'no_conflict' => true])->assertNotFound(); });