235 lines
17 KiB
PHP
235 lines
17 KiB
PHP
<?php
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Models\AuditLog;
|
|
use App\Models\ConflictDeclaration;
|
|
use App\Models\Department;
|
|
use App\Models\SupplierEvaluation;
|
|
use App\Models\SupplierImprovement;
|
|
use App\Models\User;
|
|
use App\Services\RequisitionWorkflow;
|
|
use App\Services\SupplierImprovementWorkflow;
|
|
use App\Services\WorkflowTaskInbox;
|
|
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';
|
|
require_once __DIR__.'/../Support/SupplyFixture.php';
|
|
|
|
beforeEach(function () {
|
|
$this->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');
|
|
});
|