247 lines
18 KiB
PHP
247 lines
18 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\SupplierResponse;
|
|
use App\Models\User;
|
|
use App\Services\RequisitionWorkflow;
|
|
use App\Services\SupplierResponseWorkflow;
|
|
use App\Services\WorkflowTaskInbox;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Testing\AssertableInertia as Assert;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
|
require_once __DIR__.'/../Support/SupplyFixture.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->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('d', 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 aprovada QA', 'nature' => 'goods', 'scope' => 'Âmbito fictício', 'order_ids' => [$this->f['order']->id], 'criteria' => [], 'period_start' => null, 'period_end' => null, 'conclusion' => 'Conclusão QA', 'critical_occurrences' => '', 'critical_evidence' => '']]);
|
|
$this->base = '/fornecedores/avaliacoes/'.$this->evaluation->id;
|
|
$this->url = $this->base.'/contraditorios';
|
|
$this->payload = ['title' => 'Argumentos sobre a recepção QA', 'received_on' => '2026-01-15', 'source_reference' => 'Documento fictício QA-CTR-1', 'statement' => 'O fornecedor apresenta argumentos fictícios e uma referência de evidência.'];
|
|
$this->data = ['operation_key' => (string) Str::uuid(), 'payload' => $this->payload];
|
|
$this->decision = ['outcome' => 'accepted', 'reason' => 'Argumentos e referências do exemplo conferidos por pessoa independente.', 'follow_up' => 'Preparar uma nova revisão para conferir o âmbito afectado.', 'response_reference' => 'Minuta fictícia QA-RESPOSTA-1'];
|
|
});
|
|
|
|
function responseDraft(object $test): SupplierResponse
|
|
{
|
|
$test->post($test->url, $test->data)->assertRedirect();
|
|
|
|
return SupplierResponse::query()->sole();
|
|
}
|
|
|
|
function responseInReview(object $test): SupplierResponse
|
|
{
|
|
$record = responseDraft($test);
|
|
$test->post($test->url.'/'.$record->id.'/submit', ['version' => 1])->assertRedirect();
|
|
|
|
return $record->refresh();
|
|
}
|
|
|
|
test('contradictory statement is stored recovered and shown in the dated evaluation report', function () {
|
|
$before = $this->evaluation->refresh()->getAttributes();
|
|
$record = responseDraft($this);
|
|
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->where('responses.ready', true)->where('responses.canCreate', true)->where('responses.records.0.payload.statement', $this->payload['statement'])->where('responses.records.0.canEdit', true)->has('responses.history.data', 1));
|
|
$this->get($this->base.'/relatorio')->assertInertia(fn (Assert $p) => $p->where('responses.canCreate', false)->where('responses.records.0.canEdit', false)->where('responses.records.0.canReview', false)->where('responses.records.0.id', $record->id));
|
|
expect($this->evaluation->refresh()->getAttributes())->toBe($before);
|
|
});
|
|
|
|
test('creation is canonical and idempotent while conflicting reuse is refused', function () {
|
|
responseDraft($this);
|
|
$this->post($this->url, [...$this->data, 'payload' => array_reverse($this->payload, true)])->assertRedirect();
|
|
$this->postJson($this->url, [...$this->data, 'payload' => [...$this->payload, 'title' => 'Conteúdo diferente']])->assertConflict();
|
|
expect(SupplierResponse::query()->count())->toBe(1)->and(AuditLog::query()->where('action', 'supplier_response.create')->count())->toBe(1);
|
|
});
|
|
|
|
test('incomplete and future receipt remain drafts and cannot be submitted', function () {
|
|
$this->data['payload'] = ['title' => 'Rascunho incompleto QA'];
|
|
$record = responseDraft($this);
|
|
$this->postJson($this->url.'/'.$record->id.'/submit', ['version' => 1])->assertUnprocessable()->assertJsonValidationErrors(['payload.received_on', 'payload.source_reference', 'payload.statement']);
|
|
$future = [...$this->payload, 'received_on' => today()->addDay()->toDateString()];
|
|
$this->post($this->url.'/'.$record->id.'/save', ['version' => 1, 'payload' => $future])->assertRedirect();
|
|
$this->postJson($this->url.'/'.$record->id.'/submit', ['version' => 2])->assertUnprocessable()->assertJsonValidationErrors('payload.received_on');
|
|
expect($record->refresh()->status)->toBe('draft');
|
|
});
|
|
|
|
test('all editors and submitters are excluded from deciding the response', function () {
|
|
$record = responseDraft($this);
|
|
$url = $this->url.'/'.$record->id;
|
|
$this->actingAs($this->reviewer)->post($url.'/save', ['version' => 1, 'payload' => [...$this->payload, 'statement' => 'Argumentos corrigidos por outra pessoa.']])->assertRedirect();
|
|
$this->actingAs($this->f['issuer'])->post($url.'/submit', ['version' => 2])->assertRedirect();
|
|
$this->post($url.'/decide', ['version' => 3, ...$this->decision])->assertForbidden();
|
|
$this->actingAs($this->reviewer)->post($url.'/decide', ['version' => 3, ...$this->decision])->assertForbidden();
|
|
expect($record->refresh()->status)->toBe('review');
|
|
});
|
|
|
|
test('return correction and independent decision preserve the original arguments and separate conclusions', function () {
|
|
$record = responseInReview($this);
|
|
$url = $this->url.'/'.$record->id;
|
|
$this->actingAs($this->reviewer)->post($url.'/return', ['version' => 2, 'reason' => 'Completar a referência do documento recebido.'])->assertRedirect();
|
|
$corrected = [...$this->payload, 'source_reference' => 'Documento QA-CTR-1, anexo fictício A'];
|
|
$this->actingAs($this->f['issuer'])->post($url.'/save', ['version' => 3, 'payload' => $corrected])->assertRedirect();
|
|
$this->post($url.'/submit', ['version' => 4])->assertRedirect();
|
|
$this->actingAs($this->reviewer)->post($url.'/decide', ['version' => 5, ...$this->decision])->assertRedirect();
|
|
expect($record->refresh()->status)->toBe('decided')->and($record->version)->toBe(6)->and($record->decision['follow_up'])->toBe($this->decision['follow_up']);
|
|
expect(AuditLog::query()->where('action', 'supplier_response.save')->sole()->before['payload']['source_reference'])->toBe($this->payload['source_reference']);
|
|
expect(AuditLog::query()->where('action', 'supplier_response.return')->sole()->after['review_reason'])->toContain('Completar');
|
|
});
|
|
|
|
test('accepting arguments requires documented follow up and rejection requires a reason', function () {
|
|
$record = responseInReview($this);
|
|
$url = $this->url.'/'.$record->id;
|
|
$this->actingAs($this->reviewer);
|
|
foreach (['accepted', 'partially_accepted'] as $outcome) {
|
|
$this->postJson($url.'/decide', ['version' => 2, ...$this->decision, 'outcome' => $outcome, 'follow_up' => ''])->assertUnprocessable()->assertJsonValidationErrors('follow_up');
|
|
}
|
|
$this->postJson($url.'/decide', ['version' => 2, 'outcome' => 'not_accepted', 'reason' => ''])->assertUnprocessable()->assertJsonValidationErrors('reason');
|
|
$this->post($url.'/decide', ['version' => 2, 'outcome' => 'not_accepted', 'reason' => 'Argumentos não sustentados pela evidência recebida.'])->assertRedirect();
|
|
expect($record->refresh()->decision['outcome'])->toBe('not_accepted');
|
|
});
|
|
|
|
test('stale edits and raw SQL modification of a final conclusion are refused', function () {
|
|
$record = responseDraft($this);
|
|
$url = $this->url.'/'.$record->id;
|
|
$this->post($url.'/save', ['version' => 1, 'payload' => [...$this->payload, 'title' => 'Assunto corrigido QA']])->assertRedirect();
|
|
$this->postJson($url.'/save', ['version' => 1, 'payload' => $this->payload])->assertUnprocessable()->assertJsonValidationErrors('version');
|
|
$this->post($url.'/submit', ['version' => 2])->assertRedirect();
|
|
$this->actingAs($this->reviewer)->post($url.'/decide', ['version' => 3, ...$this->decision])->assertRedirect();
|
|
$this->post($url.'/return', ['version' => 4, 'reason' => 'Tentativa sobre uma conclusão final'])->assertConflict();
|
|
expect(fn () => DB::table('supplier_responses')->where('id', $record->id)->update(['status' => 'draft']))->toThrow(QueryException::class);
|
|
expect(fn () => DB::table('supplier_responses')->where('id', $record->id)->delete())->toThrow(QueryException::class);
|
|
});
|
|
|
|
test('company department role and exact parent relationship are protected', function () {
|
|
$record = responseDraft($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($this->base)->assertNotFound();
|
|
$other = receiptFixture();
|
|
$this->actingAs($other['issuer'])->post($this->url, $this->data)->assertNotFound();
|
|
$this->actingAs($this->f['receiver'])->post($this->url, $this->data)->assertForbidden();
|
|
$this->actingAs($this->f['issuer'])->post($this->url.'/99999/submit', ['version' => 1])->assertNotFound();
|
|
DB::table('users')->where('id', $this->f['issuer']->id)->update(['purchasing_role' => 'requester']);
|
|
$this->post($this->url.'/'.$record->id.'/submit', ['version' => 1])->assertForbidden();
|
|
});
|
|
|
|
test('newly declared process conflict prevents an independent reviewer from concluding', function () {
|
|
$record = responseInReview($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' => today()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício declarado após submissão']);
|
|
$this->actingAs($this->reviewer)->post($this->url.'/'.$record->id.'/decide', ['version' => 2, ...$this->decision])->assertForbidden();
|
|
expect($record->refresh()->status)->toBe('review');
|
|
});
|
|
|
|
test('forged metadata and unapproved evaluations cannot create response records', function () {
|
|
foreach (['tenant_id' => 999, 'decision' => ['outcome' => 'accepted'], 'status' => 'decided', 'reviewed_by' => 999] as $key => $value) {
|
|
$this->postJson($this->url, [...$this->data, $key => $value])->assertUnprocessable();
|
|
}
|
|
$draft = $this->evaluation->replicate();
|
|
$draft->operation_key = (string) Str::uuid();
|
|
$draft->status = 'draft';
|
|
$draft->save();
|
|
$this->post('/fornecedores/avaliacoes/'.$draft->id.'/contraditorios', $this->data)->assertConflict();
|
|
expect(SupplierResponse::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('failed audit rolls the response edit back without changing the approved evaluation', function () {
|
|
$record = responseDraft($this);
|
|
$before = $this->evaluation->refresh()->getAttributes();
|
|
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected audit failure'));
|
|
expect(fn () => app(SupplierResponseWorkflow::class)->execute($this->f['issuer'], $this->evaluation->id, $record->id, 'save', ['version' => 1, 'payload' => [...$this->payload, 'title' => 'Não deve persistir']]))->toThrow(RuntimeException::class);
|
|
expect($record->refresh()->version)->toBe(1)->and($record->payload['title'])->toBe($this->payload['title'])->and($this->evaluation->refresh()->getAttributes())->toBe($before);
|
|
});
|
|
|
|
function qualityResponseTasks(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-response:')));
|
|
}
|
|
|
|
test('response task follows preparation review return and decision preserving approved source', function () {
|
|
$before = $this->evaluation->refresh()->getAttributes();
|
|
$record = responseDraft($this);
|
|
expect(qualityResponseTasks($this, $this->f['issuer']))->toHaveCount(1)->and(qualityResponseTasks($this, $this->reviewer))->toBe([]);
|
|
$this->actingAs($this->f['issuer'])->post($this->url.'/'.$record->id.'/submit', ['version' => 1])->assertRedirect();
|
|
expect(qualityResponseTasks($this, $this->f['issuer']))->toBe([]);
|
|
$tasks = qualityResponseTasks($this, $this->reviewer);
|
|
expect($tasks)->toHaveCount(1)->and($tasks[0]['href'])->toBe($this->base.'#contraditorio-'.$record->id)->and($tasks[0]['due_on'])->toBeNull();
|
|
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->where('responses.records.0.canReview', true));
|
|
$this->post($this->url.'/'.$record->id.'/return', ['version' => 2, 'reason' => 'Completar a referência da origem dos argumentos.'])->assertRedirect();
|
|
expect(qualityResponseTasks($this, $this->reviewer))->toBe([])->and(qualityResponseTasks($this, $this->f['issuer']))->toHaveCount(1);
|
|
$this->post($this->url.'/'.$record->id.'/submit', ['version' => 3])->assertRedirect();
|
|
$this->actingAs($this->reviewer)->post($this->url.'/'.$record->id.'/decide', ['version' => 4, ...$this->decision])->assertRedirect();
|
|
expect(qualityResponseTasks($this, $this->reviewer))->toBe([])->and(qualityResponseTasks($this, $this->f['issuer']))->toBe([])
|
|
->and($this->evaluation->fresh()->getAttributes())->toBe($before);
|
|
});
|
|
|
|
test('response task excludes every contributor and does not disclose arguments or evidence', function () {
|
|
$record = responseDraft($this);
|
|
$this->actingAs($this->reviewer)->post($this->url.'/'.$record->id.'/save', ['version' => 1, 'payload' => [...$this->payload, 'statement' => 'Argumentos actualizados por outro preparador.']])->assertRedirect();
|
|
expect(qualityResponseTasks($this, $this->reviewer))->toHaveCount(1);
|
|
$this->post($this->url.'/'.$record->id.'/submit', ['version' => 2])->assertRedirect();
|
|
expect(qualityResponseTasks($this, $this->reviewer))->toBe([])->and(qualityResponseTasks($this, $this->f['issuer']))->toBe([]);
|
|
$independent = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
|
$tasks = qualityResponseTasks($this, $independent);
|
|
expect($tasks)->toHaveCount(1)->and(array_key_exists('payload', $tasks[0]))->toBeFalse()->and(array_key_exists('decision', $tasks[0]))->toBeFalse();
|
|
});
|
|
|
|
test('response task excludes foreign company department current conflicts and maintenance', function () {
|
|
$record = responseInReview($this);
|
|
$other = receiptFixture();
|
|
expect(qualityResponseTasks($this, $other['issuer']))->toBe([]);
|
|
$dept = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro âmbito']);
|
|
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $dept->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
|
expect(qualityResponseTasks($this, $head))->toBe([]);
|
|
supplyGrant($this->reviewer, ['finance']);
|
|
DB::table('organization_memberships')->where('user_id', $this->reviewer->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
|
expect(qualityResponseTasks($this, $this->reviewer))->toBe([]);
|
|
$this->post($this->url.'/'.$record->id.'/decide', ['version' => 2, ...$this->decision])->assertForbidden();
|
|
DB::table('organization_memberships')->where('user_id', $this->reviewer->id)->update(['maintenance' => false]);
|
|
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' => today()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício nos argumentos do fornecedor.']);
|
|
expect(qualityResponseTasks($this, $this->reviewer))->toBe([]);
|
|
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->where('responses.records.0.canReview', false));
|
|
});
|
|
|
|
test('response task keeps old pending records beyond one hundred and pages them without writes', function () {
|
|
$record = responseInReview($this);
|
|
for ($i = 0; $i < 104; $i++) {
|
|
$copy = $record->replicate();
|
|
$copy->operation_key = (string) Str::uuid();
|
|
$copy->save();
|
|
}
|
|
$before = AuditLog::query()->count();
|
|
expect(qualityResponseTasks($this, $this->reviewer))->toHaveCount(105);
|
|
$this->get('/requisicoes/tarefas?module=purchases&search=CTR-')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 105)->has('tasks.data', 25)->where('tasks.data.0.id', 'supplier-response:'.$record->id.':review:2'));
|
|
$this->get('/requisicoes/tarefas?module=purchases&search=CTR-&page=5')->assertInertia(fn (Assert $p) => $p->has('tasks.data', 5));
|
|
expect(AuditLog::query()->count())->toBe($before);
|
|
});
|
|
|
|
test('response task and commands reject a revoked role even with a previously loaded actor', function () {
|
|
$record = responseInReview($this);
|
|
expect(qualityResponseTasks($this, $this->reviewer))->toHaveCount(1);
|
|
DB::table('users')->where('id', $this->reviewer->id)->update(['purchasing_role' => 'requester']);
|
|
expect(qualityResponseTasks($this, $this->reviewer->fresh()))->toBe([]);
|
|
$this->actingAs($this->reviewer);
|
|
try {
|
|
app(SupplierResponseWorkflow::class)->execute($this->reviewer, $this->evaluation->id, $record->id, 'decide', ['version' => 2, ...$this->decision]);
|
|
$this->fail('O papel revogado não pode decidir.');
|
|
} catch (HttpException $exception) {
|
|
expect($exception->getStatusCode())->toBe(403);
|
|
}
|
|
expect($record->refresh()->status)->toBe('review')->and($record->version)->toBe(2);
|
|
});
|