f = receiptFixture(); $this->planner = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id, 'purchasing_role' => PurchasingRole::Finance]); $this->closer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id, 'purchasing_role' => PurchasingRole::Admin]); $this->evaluation = SupplierEvaluation::query()->create(['tenant_id' => $this->f['tenant']->id, 'supplier_id' => $this->f['order']->supplier_id, 'department_id' => $this->f['r']->department_id, 'created_by' => $this->f['issuer']->id, 'operation_key' => (string) Str::uuid(), 'creation_hash' => str_repeat('a', 64), 'status' => 'approved', 'contributors' => [$this->f['issuer']->id], 'supplier_snapshot' => ['id' => $this->f['order']->supplier_id, 'name' => 'Fornecedor QA', 'nuit' => '123456789'], 'payload' => ['title' => 'Avaliação qualitativa QA', 'nature' => 'goods', 'scope' => 'Âmbito fictício', 'order_ids' => [$this->f['order']->id], 'criteria' => [], 'period_start' => '2026-01-01', 'period_end' => '2026-01-31', 'conclusion' => 'Conclusão QA', 'critical_occurrences' => '', 'critical_evidence' => '']]); $this->url = '/fornecedores/avaliacoes/'.$this->evaluation->id.'/medidas'; $this->data = ['title' => 'Melhorar identificação dos volumes', 'description' => 'Identificar os volumes segundo a referência da ordem.', 'responsible_id' => $this->f['issuer']->id, 'due_on' => '2026-01-31', 'operation_key' => (string) Str::uuid()]; $this->execution = ['result' => 'Identificação do exemplo conferida.', 'evidence' => 'Referência fictícia de recepção QA.']; $this->actingAs($this->planner); }); function improvementDraft(object $test): SupplierImprovement { $test->post($test->url, $test->data)->assertRedirect(); return SupplierImprovement::query()->sole(); } function improvementInReview(object $test): SupplierImprovement { $measure = improvementDraft($test); $test->actingAs($test->f['issuer'])->post($test->url.'/'.$measure->id.'/submit', ['version' => 1, ...$test->execution])->assertRedirect(); return $measure->refresh(); } test('improvements are linked to the exact approved evaluation and included in its report', function () { $before = $this->evaluation->refresh()->getAttributes(); $m = improvementDraft($this); $this->get('/fornecedores/avaliacoes/'.$this->evaluation->id)->assertInertia(fn (Assert $p) => $p->where('improvements.canCreate', true)->has('improvements.records', 1)->where('improvements.records.0.canPlan', true)->where('improvements.records.0.canExecute', false)); $this->actingAs($this->f['issuer'])->get('/fornecedores/avaliacoes/'.$this->evaluation->id)->assertInertia(fn (Assert $p) => $p->where('improvements.records.0.canExecute', true)); $this->get('/fornecedores/avaliacoes/'.$this->evaluation->id.'/relatorio')->assertInertia(fn (Assert $p) => $p->where('improvements.canCreate', false)->where('improvements.records.0.canPlan', false)->where('improvements.records.0.canExecute', false)->where('improvements.records.0.title', $m->title)); expect($this->evaluation->refresh()->getAttributes())->toBe($before); }); test('creation retry is idempotent and reusing the operation with another plan is refused', function () { improvementDraft($this); $this->post($this->url, $this->data)->assertRedirect(); $this->postJson($this->url, [...$this->data, 'title' => 'Outra medida no mesmo pedido'])->assertConflict(); expect(SupplierImprovement::query()->count())->toBe(1)->and(AuditLog::query()->where('action', 'supplier_improvement.create')->count())->toBe(1); }); test('only the responsible person can report execution and cannot submit without evidence', function () { $m = improvementDraft($this); $url = $this->url.'/'.$m->id; $this->post($url.'/progress', ['version' => 1, ...$this->execution])->assertForbidden(); $this->actingAs($this->f['issuer'])->postJson($url.'/submit', ['version' => 1, 'result' => 'Resultado sem referência'])->assertUnprocessable()->assertJsonValidationErrors('evidence'); expect($m->refresh()->status)->toBe('open')->and($m->version)->toBe(1); }); test('creator and all responsible people are excluded from independent closure', function () { $m = improvementInReview($this); $url = $this->url.'/'.$m->id; $this->post($url.'/close', ['version' => 2, 'reason' => 'Tentativa do próprio responsável'])->assertForbidden(); $this->actingAs($this->planner)->post($url.'/close', ['version' => 2, 'reason' => 'Tentativa do autor do plano'])->assertForbidden(); $this->actingAs($this->closer)->post($url.'/close', ['version' => 2, 'reason' => 'Resultado e evidência conferidos independentemente.'])->assertRedirect(); expect($m->refresh()->status)->toBe('closed')->and($m->reviewed_by)->toBe($this->closer->id); }); test('return correction and closure retain the separate decisions and evidence history', function () { $m = improvementInReview($this); $url = $this->url.'/'.$m->id; $this->actingAs($this->closer)->post($url.'/return', ['version' => 2, 'reason' => 'Acrescentar referência documental mais precisa.'])->assertRedirect(); $corrected = [...$this->execution, 'evidence' => 'Referência QA com identificação suplementar.']; $this->actingAs($this->f['issuer'])->post($url.'/progress', ['version' => 3, ...$corrected])->assertRedirect(); $this->post($url.'/submit', ['version' => 4, ...$corrected])->assertRedirect(); $this->actingAs($this->closer)->post($url.'/close', ['version' => 5, 'reason' => 'Referência corrigida conferida independentemente.'])->assertRedirect(); expect($m->refresh()->status)->toBe('closed')->and($m->version)->toBe(6); expect(AuditLog::query()->where('action', 'supplier_improvement.return')->sole()->after['review_reason'])->toContain('mais precisa'); expect(AuditLog::query()->where('action', 'supplier_improvement.progress')->sole()->before['evidence'])->toBe($this->execution['evidence']); }); test('replanning preserves the old deadline responsible and justification and stale writes fail', function () { $m = improvementDraft($this); $plan = ['version' => 1, 'title' => $m->title, 'description' => $m->description, 'responsible_id' => $this->closer->id, 'due_on' => '2026-02-28', 'reason' => 'Reagendar e transferir a execução de forma documentada.']; $url = $this->url.'/'.$m->id; $this->post($url.'/plan', $plan)->assertRedirect(); $this->postJson($url.'/plan', $plan)->assertUnprocessable()->assertJsonValidationErrors('version'); $this->actingAs($this->f['issuer'])->post($url.'/submit', ['version' => 2, ...$this->execution])->assertForbidden(); $event = AuditLog::query()->where('action', 'supplier_improvement.plan')->sole(); expect($event->before['due_on'])->toBe('2026-01-31')->and($event->before['responsible_id'])->toBe($this->f['issuer']->id)->and($event->after['change_reason'])->toBe($plan['reason']); expect($m->refresh()->contributors)->toContain($this->f['issuer']->id, $this->planner->id, $this->closer->id); $this->actingAs($this->closer)->post($url.'/submit', ['version' => 2, ...$this->execution])->assertRedirect(); $this->post($url.'/close', ['version' => 3, 'reason' => 'Novo responsável também não encerra sozinho'])->assertForbidden(); }); test('closed measures cannot be changed or deleted even by raw SQL', function () { $m = improvementInReview($this); $this->actingAs($this->closer)->post($this->url.'/'.$m->id.'/close', ['version' => 2, 'reason' => 'Conferência independente do exemplo QA.'])->assertRedirect(); $this->post($this->url.'/'.$m->id.'/return', ['version' => 3, 'reason' => 'Tentativa posterior ao encerramento'])->assertConflict(); expect(fn () => DB::table('supplier_improvements')->where('id', $m->id)->update(['status' => 'open']))->toThrow(QueryException::class); expect(fn () => DB::table('supplier_improvements')->where('id', $m->id)->delete())->toThrow(QueryException::class); }); test('company department current role and evaluation relationship are enforced', function () { $m = improvementDraft($this); $department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']); $head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); $this->actingAs($head)->post($this->url, $this->data)->assertNotFound(); $this->get('/fornecedores/avaliacoes/'.$this->evaluation->id)->assertNotFound(); $other = receiptFixture(); $this->actingAs($other['issuer'])->post($this->url, $this->data)->assertNotFound(); $this->actingAs($this->planner)->postJson($this->url, [...$this->data, 'operation_key' => (string) Str::uuid(), 'responsible_id' => $other['issuer']->id])->assertUnprocessable(); $this->post($this->url.'/999999/plan', ['version' => 1, ...array_diff_key($this->data, ['operation_key' => true]), 'reason' => 'Tentativa sobre uma medida inexistente'])->assertNotFound(); DB::table('users')->where('id', $this->planner->id)->update(['purchasing_role' => 'requester']); $this->post($this->url, $this->data)->assertForbidden(); expect($m->refresh()->version)->toBe(1); }); test('process conflicts are rechecked for the acting user and the assignee', function () { $m = improvementInReview($this); ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->closer->id, 'requisition_id' => $this->f['r']->id, 'has_conflict' => true, 'known_since' => now()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício após o pedido de encerramento']); $this->actingAs($this->closer)->post($this->url.'/'.$m->id.'/close', ['version' => 2, 'reason' => 'Tentativa com conflito de processo'])->assertForbidden(); $this->actingAs($this->planner)->postJson($this->url, [...$this->data, 'operation_key' => (string) Str::uuid(), 'responsible_id' => $this->closer->id])->assertUnprocessable()->assertJsonValidationErrors('responsible_id'); }); test('unapproved evaluations and forged metadata cannot receive measures', function () { $draft = $this->evaluation->replicate(); $draft->operation_key = (string) Str::uuid(); $draft->status = 'draft'; $draft->save(); $this->post('/fornecedores/avaliacoes/'.$draft->id.'/medidas', $this->data)->assertConflict(); foreach (['status' => 'closed', 'tenant_id' => 999, 'created_by' => 999, 'reviewed_by' => 999] as $field => $value) { $this->postJson($this->url, [...$this->data, $field => $value])->assertUnprocessable(); } expect(SupplierImprovement::query()->count())->toBe(0); }); test('audit failure rolls a measure and its progress back without changing the evaluation', function () { $m = improvementDraft($this); $this->actingAs($this->f['issuer']); $this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected audit failure')); expect(fn () => app(SupplierImprovementWorkflow::class)->execute($this->f['issuer'], $this->evaluation->id, $m->id, 'progress', ['version' => 1, ...$this->execution]))->toThrow(RuntimeException::class); expect($m->refresh()->version)->toBe(1)->and($m->result)->toBeNull()->and($this->evaluation->refresh()->status)->toBe('approved'); }); function improvementTasks(object $test, User $actor): array { $test->actingAs($actor); return array_values(array_filter(app(WorkflowTaskInbox::class)->build($actor, 'procurement'), fn ($row) => str_starts_with($row['id'], 'supplier-improvement:'))); } test('improvement tasks follow assignment independent review return and closure without changing the approved source', function () { $before = $this->evaluation->refresh()->getAttributes(); $m = improvementDraft($this); expect(improvementTasks($this, $this->planner))->toBe([]); $tasks = improvementTasks($this, $this->f['issuer']); expect($tasks)->toHaveCount(1)->and($tasks[0]['due_on'])->toBe($this->data['due_on']) ->and($tasks[0]['href'])->toBe('/fornecedores/avaliacoes/'.$this->evaluation->id.'#medida-'.$m->id); $this->post($this->url.'/'.$m->id.'/submit', ['version' => 1, ...$this->execution])->assertRedirect(); expect(improvementTasks($this, $this->f['issuer']))->toBe([]) ->and(improvementTasks($this, $this->planner))->toBe([]); $tasks = improvementTasks($this, $this->closer); expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('supplier_improvement_review'); $this->post($this->url.'/'.$m->id.'/return', ['version' => 2, 'reason' => 'Completar referência da evidência apresentada.'])->assertRedirect(); expect(improvementTasks($this, $this->closer))->toBe([])->and(improvementTasks($this, $this->f['issuer']))->toHaveCount(1); $this->post($this->url.'/'.$m->id.'/submit', ['version' => 3, ...$this->execution])->assertRedirect(); $this->actingAs($this->closer)->post($this->url.'/'.$m->id.'/close', ['version' => 4, 'reason' => 'Conferência independente da execução e evidência.'])->assertRedirect(); expect(improvementTasks($this, $this->closer))->toBe([])->and(improvementTasks($this, $this->f['issuer']))->toBe([]) ->and($this->evaluation->refresh()->getAttributes())->toBe($before); }); test('improvement tasks exclude other companies departments and current conflicts', function () { improvementInReview($this); $other = receiptFixture(); expect(improvementTasks($this, $other['issuer']))->toBe([]); $department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro âmbito']); $head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); expect(improvementTasks($this, $head))->toBe([]); expect(improvementTasks($this, $this->closer))->toHaveCount(1); ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->closer->id, 'requisition_id' => $this->f['r']->id, 'has_conflict' => true, 'known_since' => today()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício para testar o encaminhamento.']); expect(improvementTasks($this, $this->closer))->toBe([]); }); test('improvement tasks retain old pending measures beyond the first hundred and disclose no evidence', function () { $m = improvementDraft($this); for ($i = 0; $i < 104; $i++) { $copy = $m->replicate(); $copy->operation_key = (string) Str::uuid(); $copy->save(); } $before = AuditLog::query()->count(); $tasks = improvementTasks($this, $this->f['issuer']); expect($tasks)->toHaveCount(105)->and($tasks[0]['id'])->toStartWith('supplier-improvement:'.$m->id.':') ->and(array_key_exists('evidence', $tasks[0]))->toBeFalse()->and(AuditLog::query()->count())->toBe($before); }); test('improvement task eligibility and page actions share the current role and approved evaluation', function () { $m = improvementInReview($this); expect(improvementTasks($this, $this->closer))->toHaveCount(1); $this->get('/fornecedores/avaliacoes/'.$this->evaluation->id)->assertInertia(fn (Assert $p) => $p->where('improvements.records.0.canReview', true)); DB::table('users')->where('id', $this->closer->id)->update(['purchasing_role' => 'requester']); expect(improvementTasks($this, $this->closer->fresh()))->toBe([]); $this->post($this->url.'/'.$m->id.'/close', ['version' => 2, 'reason' => 'Tentativa após revogação da função anterior.'])->assertForbidden(); expect($m->refresh()->status)->toBe('review'); }); test('improvement tasks keep maintenance accounts read only and expired memberships ineligible', function () { $m = improvementInReview($this); supplyGrant($this->closer, ['finance']); DB::table('organization_memberships')->where('user_id', $this->closer->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); expect(improvementTasks($this, $this->closer))->toBe([]); $this->get('/fornecedores/avaliacoes/'.$this->evaluation->id)->assertInertia(fn (Assert $p) => $p->where('improvements.records.0.canReview', false)); $this->post($this->url.'/'.$m->id.'/close', ['version' => 2, 'reason' => 'Tentativa de decisão com acesso de manutenção.'])->assertForbidden(); DB::table('organization_memberships')->where('user_id', $this->closer->id)->update(['expires_at' => now()->subMinute()]); expect(improvementTasks($this, $this->closer))->toBe([]); expect($m->refresh()->status)->toBe('review'); });