166 lines
12 KiB
PHP
166 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Models\AuditLog;
|
|
use App\Models\ConflictDeclaration;
|
|
use App\Models\Department;
|
|
use App\Models\Supplier;
|
|
use App\Models\SupplierEvaluation;
|
|
use App\Models\User;
|
|
use App\Services\RequisitionWorkflow;
|
|
use App\Services\SupplierEvaluationWorkflow;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Testing\AssertableInertia as Assert;
|
|
|
|
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
|
|
|
beforeEach(function () {
|
|
$this->f = receiptFixture();
|
|
$this->reviewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id, 'purchasing_role' => PurchasingRole::Finance]);
|
|
$this->actingAs($this->f['issuer']);
|
|
$this->data = ['supplier_id' => $this->f['order']->supplier_id, 'operation_key' => (string) Str::uuid(), 'payload' => [
|
|
'title' => 'Avaliação qualitativa fictícia', 'nature' => 'goods', 'scope' => 'Recepção e conformidade dos artigos do teste isolado.',
|
|
'period_start' => '2026-01-01', 'period_end' => '2026-01-31', 'order_ids' => [$this->f['order']->id],
|
|
'criteria' => [['label' => 'Conformidade da entrega', 'result' => 'observed', 'reason' => 'Requisitos da ordem conferidos no ensaio.', 'evidence' => 'Referência fictícia: recepção QA'],
|
|
['label' => 'Serviço pós-venda', 'result' => 'not_applicable', 'reason' => 'Não incluído neste âmbito de avaliação.', 'evidence' => '']],
|
|
'conclusion' => 'Conclusão qualitativa exclusivamente fictícia.', 'critical_occurrences' => '', 'critical_evidence' => '',
|
|
]];
|
|
});
|
|
|
|
function evaluationDraft(object $test): SupplierEvaluation
|
|
{
|
|
$test->post('/fornecedores/avaliacoes', $test->data)->assertRedirect();
|
|
|
|
return SupplierEvaluation::query()->sole();
|
|
}
|
|
|
|
function submittedEvaluation(object $test): SupplierEvaluation
|
|
{
|
|
$e = evaluationDraft($test);
|
|
$test->post('/fornecedores/avaliacoes/'.$e->id.'/submit', ['version' => 1])->assertRedirect();
|
|
|
|
return $e->refresh();
|
|
}
|
|
|
|
test('supplier evaluation is persisted recovered filtered and linked to its report', function () {
|
|
$e = evaluationDraft($this);
|
|
expect($e->status)->toBe('draft')->and($e->version)->toBe(1)->and($e->revision)->toBe(1);
|
|
$this->get('/fornecedores/avaliacoes')->assertInertia(fn (Assert $p) => $p->where('ready', true)->where('records.total', 1));
|
|
$this->get('/fornecedores/avaliacoes?status=approved')->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
|
|
$this->get('/fornecedores/avaliacoes/'.$e->id)->assertInertia(fn (Assert $p) => $p->where('evaluation.payload.scope', $this->data['payload']['scope'])->where('evaluation.version', 1)->where('canEdit', true)->has('history.data', 1));
|
|
$this->get('/fornecedores/avaliacoes/'.$e->id.'/relatorio')->assertInertia(fn (Assert $p) => $p->where('reportOnly', true)->where('canEdit', false)->where('evaluation.status', 'draft'));
|
|
});
|
|
|
|
test('creation is idempotent and normalisation does not make JSON key order significant', function () {
|
|
$e = evaluationDraft($this);
|
|
$this->post('/fornecedores/avaliacoes', [...$this->data, 'payload' => array_reverse($this->data['payload'], true)])->assertRedirect('/fornecedores/avaliacoes/'.$e->id);
|
|
expect(SupplierEvaluation::query()->count())->toBe(1)->and(AuditLog::query()->where('action', 'supplier_evaluation.created')->count())->toBe(1);
|
|
$this->postJson('/fornecedores/avaliacoes', [...$this->data, 'payload' => [...$this->data['payload'], 'title' => 'Other content for same key']])->assertConflict();
|
|
});
|
|
|
|
test('edits retain snapshots and stale writes cannot overwrite a newer draft', function () {
|
|
$e = evaluationDraft($this);
|
|
$url = '/fornecedores/avaliacoes/'.$e->id;
|
|
$new = [...$this->data['payload'], 'scope' => 'Âmbito alterado deliberadamente no teste.'];
|
|
$this->put($url, ['version' => 1, 'payload' => $new])->assertRedirect();
|
|
$this->putJson($url, ['version' => 1, 'payload' => $this->data['payload']])->assertUnprocessable()->assertJsonValidationErrors('version');
|
|
expect($e->refresh()->payload['scope'])->toBe($new['scope'])->and($e->version)->toBe(2);
|
|
$event = AuditLog::query()->where('action', 'supplier_evaluation.updated')->sole();
|
|
expect($event->before['payload']['scope'])->toBe($this->data['payload']['scope'])->and($event->after['payload']['scope'])->toBe($new['scope']);
|
|
});
|
|
|
|
test('every editor and submitter is excluded from approving that revision', function () {
|
|
$e = evaluationDraft($this);
|
|
$url = '/fornecedores/avaliacoes/'.$e->id;
|
|
$new = [...$this->data['payload'], 'conclusion' => 'Alteração da outra pessoa que deve ficar identificada.'];
|
|
$this->actingAs($this->reviewer)->put($url, ['version' => 1, 'payload' => $new])->assertRedirect();
|
|
$this->actingAs($this->f['issuer'])->put($url, ['version' => 2, 'payload' => $this->data['payload']])->assertRedirect();
|
|
$this->post($url.'/submit', ['version' => 3])->assertRedirect();
|
|
$this->post($url.'/approve', ['version' => 4, 'reason' => 'Tentativa de auto aprovação'])->assertForbidden();
|
|
$this->actingAs($this->reviewer)->post($url.'/approve', ['version' => 4, 'reason' => 'Outro editor também participou'])->assertForbidden();
|
|
expect($e->refresh()->status)->toBe('review')->and($e->contributors)->toContain($this->f['issuer']->id, $this->reviewer->id);
|
|
});
|
|
|
|
test('insufficient evidence remains explicit and needs correction before independent approval', function () {
|
|
$this->data['payload']['criteria'][0]['result'] = 'insufficient';
|
|
$this->data['payload']['criteria'][0]['evidence'] = '';
|
|
$e = submittedEvaluation($this);
|
|
$url = '/fornecedores/avaliacoes/'.$e->id;
|
|
$this->actingAs($this->reviewer)->postJson($url.'/approve', ['version' => 2, 'reason' => 'Não se pode presumir evidência'])->assertUnprocessable();
|
|
$this->post($url.'/return', ['version' => 2, 'reason' => 'Completar a referência da recepção observada.'])->assertRedirect();
|
|
$corrected = $this->data['payload'];
|
|
$corrected['criteria'][0] = ['label' => 'Conformidade da entrega', 'result' => 'observed', 'reason' => 'Requisitos efectivamente conferidos.', 'evidence' => 'Referência fictícia: recepção QA'];
|
|
$this->actingAs($this->f['issuer'])->put($url, ['version' => 3, 'payload' => $corrected])->assertRedirect();
|
|
$this->post($url.'/submit', ['version' => 4])->assertRedirect();
|
|
$this->actingAs($this->reviewer)->post($url.'/approve', ['version' => 5, 'reason' => 'Âmbito e evidências conferidos independentemente.'])->assertRedirect();
|
|
expect($e->refresh()->status)->toBe('approved')->and($e->version)->toBe(6);
|
|
expect(AuditLog::query()->where('action', 'supplier_evaluation.return')->sole()->after['review_reason'])->toContain('Completar');
|
|
});
|
|
|
|
test('approved evaluation is immutable and a new revision preserves the prior approved report', function () {
|
|
$e = submittedEvaluation($this);
|
|
$url = '/fornecedores/avaliacoes/'.$e->id;
|
|
$this->actingAs($this->reviewer)->post($url.'/approve', ['version' => 2, 'reason' => 'Evidências e âmbito conferidos independentemente.'])->assertRedirect();
|
|
$e->refresh();
|
|
$this->actingAs($this->f['issuer'])->put($url, ['version' => 3, 'payload' => $this->data['payload']])->assertConflict();
|
|
expect(fn () => DB::table('supplier_evaluations')->where('id', $e->id)->update(['status' => 'draft']))->toThrow(QueryException::class);
|
|
expect(fn () => DB::table('supplier_evaluations')->where('id', $e->id)->delete())->toThrow(QueryException::class);
|
|
$this->post($url.'/rever', ['version' => 3])->assertRedirect();
|
|
$child = SupplierEvaluation::query()->where('previous_id', $e->id)->sole();
|
|
$this->post($url.'/rever', ['version' => 3])->assertRedirect('/fornecedores/avaliacoes/'.$child->id);
|
|
expect(SupplierEvaluation::query()->count())->toBe(2)->and($child->revision)->toBe(2)->and($child->status)->toBe('draft');
|
|
Supplier::query()->whereKey($e->supplier_id)->update(['name' => 'Nome posterior do cadastro']);
|
|
$this->get($url.'/relatorio')->assertInertia(fn (Assert $p) => $p->where('evaluation.status', 'approved')->where('evaluation.version', 3)->where('evaluation.supplier_snapshot.name', $e->supplier_snapshot['name'])->where('nextRevisionId', $child->id));
|
|
});
|
|
|
|
test('evaluation endpoints enforce company department profile and current-role access', function () {
|
|
$e = evaluationDraft($this);
|
|
$url = '/fornecedores/avaliacoes/'.$e->id;
|
|
$department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Other department']);
|
|
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
|
$this->actingAs($head)->get($url)->assertNotFound();
|
|
$this->get('/fornecedores/avaliacoes')->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
|
|
$this->actingAs($this->f['receiver'])->get($url)->assertForbidden();
|
|
$this->post('/fornecedores/avaliacoes', $this->data)->assertForbidden();
|
|
$other = receiptFixture();
|
|
$this->actingAs($other['issuer'])->get($url)->assertNotFound();
|
|
$this->actingAs($this->f['issuer']);
|
|
DB::table('users')->where('id', $this->f['issuer']->id)->update(['purchasing_role' => 'requester']);
|
|
$this->post($url.'/submit', ['version' => 1])->assertForbidden();
|
|
auth()->logout();
|
|
$this->get($url)->assertRedirect('/login');
|
|
});
|
|
|
|
test('forged metadata and incompatible orders or dates are refused without creating evaluations', function () {
|
|
foreach ([['tenant_id' => 999], ['created_by' => 999], ['supplier_snapshot' => ['name' => 'Forged']]] as $forged) {
|
|
$this->postJson('/fornecedores/avaliacoes', [...$this->data, ...$forged])->assertUnprocessable();
|
|
}
|
|
$this->postJson('/fornecedores/avaliacoes', [...$this->data, 'payload' => [...$this->data['payload'], 'period_end' => '2025-12-01']])->assertUnprocessable();
|
|
$this->postJson('/fornecedores/avaliacoes', [...$this->data, 'payload' => [...$this->data['payload'], 'order_ids' => [999999]]])->assertUnprocessable();
|
|
expect(SupplierEvaluation::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('missing critical evidence prevents submission and no supplier eligibility is changed', function () {
|
|
$this->data['payload']['critical_occurrences'] = 'Ocorrência fictícia que exige referência.';
|
|
$e = evaluationDraft($this);
|
|
$supplier = Supplier::query()->findOrFail($e->supplier_id)->getAttributes();
|
|
$this->postJson('/fornecedores/avaliacoes/'.$e->id.'/submit', ['version' => 1])->assertUnprocessable()->assertJsonValidationErrors('payload.critical_evidence');
|
|
expect($e->refresh()->status)->toBe('draft')->and(Supplier::query()->findOrFail($e->supplier_id)->getAttributes())->toBe($supplier);
|
|
});
|
|
|
|
test('a failed audit rolls the evaluation change back atomically', function () {
|
|
$e = evaluationDraft($this);
|
|
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected audit failure'));
|
|
expect(fn () => app(SupplierEvaluationWorkflow::class)->save($this->f['issuer'], $e->id, 1, [...$this->data['payload'], 'conclusion' => 'This must be rolled back']))->toThrow(RuntimeException::class);
|
|
expect($e->refresh()->version)->toBe(1)->and($e->payload['conclusion'])->toBe($this->data['payload']['conclusion']);
|
|
});
|
|
|
|
test('a newly declared process conflict prevents review of the stored evaluation', function () {
|
|
$e = submittedEvaluation($this);
|
|
ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->reviewer->id, 'requisition_id' => $this->f['r']->id, 'has_conflict' => true, 'known_since' => now()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício declarado após a submissão']);
|
|
$this->actingAs($this->reviewer)->post('/fornecedores/avaliacoes/'.$e->id.'/approve', ['version' => 2, 'reason' => 'Tentativa após declaração de conflito'])->assertForbidden();
|
|
expect($e->refresh()->status)->toBe('review')->and($e->version)->toBe(2);
|
|
});
|