f = recommendationFixture();
$this->url = '/requisicoes/'.$this->f['r']->id.'/recomendacao';
$this->awardUrl = '/requisicoes/'.$this->f['r']->id.'/adjudicacao';
$this->actingAs($this->f['buyer']);
});
function recommendationAttempt(object $test, array $changes = []): RecommendationAttempt
{
$test->actingAs($test->f['buyer'])->post($test->url.'/tentativas', [...$test->f['proposal'], ...$changes])->assertSessionHasNoErrors();
return RecommendationAttempt::query()->latest('id')->firstOrFail();
}
function recommendationConfer(object $test, RecommendationAttempt $attempt, ?User $actor = null, string $verdict = 'approved'): void
{
$test->actingAs($actor ?? $test->f['reviewer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => $verdict, 'reason' => 'Conferência de recomendação fictícia'])->assertSessionHasNoErrors();
}
test('company recommendation requires quorum and binds genuine award and issued order', function () {
$before = app(ProposalComparison::class)->inspect($this->f['r']);
$attempt = recommendationAttempt($this);
$this->get($this->awardUrl)->assertInertia(fn (Assert $p) => $p->where('canSelect', false));
$this->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
recommendationConfer($this, $attempt);
expect(app(FormalRecommendation::class)->status($attempt, $this->f['adoption']))->toBe('pending')->and(BudgetEntry::query()->count())->toBe(0);
recommendationConfer($this, $attempt, $this->f['head']);
expect(app(ProposalComparison::class)->inspect($this->f['r']))->toBe($before);
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasNoErrors();
$award = PurchaseAward::query()->sole();
expect($award->recommendation_snapshot['attempt_id'])->toBe($attempt->id)->and($award->recommendation_snapshot['review_ids'])->toHaveCount(2);
$this->actingAs($this->f['head'])->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
$this->actingAs($this->f['buyer'])->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors();
$order = PurchaseOrder::query()->sole();
expect($order->snapshot['recommendation'])->toBe($award->recommendation_snapshot);
$this->f['quotes'][0]->update(['reference' => 'Alteração posterior à emissão']);
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors();
expect(PurchaseOrder::query()->count())->toBe(1)->and($order->refresh()->snapshot['quotation']['reference'])->toBe('PROP-TEST');
$this->post($this->url.'/tentativas', [...$this->f['proposal'], 'previous_id' => $attempt->id])->assertSessionHasErrors('recommendation');
});
test('recommendation retries count one person once and returned attempt cannot authorize award', function () {
$attempt = recommendationAttempt($this);
recommendationConfer($this, $attempt);
recommendationConfer($this, $attempt);
expect(RecommendationReview::query()->count())->toBe(1)->and(AuditLog::query()->where('action', 'recommendation.reviewed')->count())->toBe(1);
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'returned', 'reason' => 'Outra resposta para a mesma pessoa'])->assertSessionHasErrors('recommendation');
recommendationConfer($this, $attempt, $this->f['other_reviewer'], 'returned');
expect(app(FormalRecommendation::class)->status($attempt, $this->f['adoption']))->toBe('returned')->and(AdmissionDecision::query()->where('outcome', 'admit')->count())->toBe(3);
$next = recommendationAttempt($this, ['previous_id' => $attempt->id]);
expect($next->revision)->toBe(2)->and(RecommendationReview::query()->where('recommendation_attempt_id', $next->id)->count())->toBe(0);
$this->post($this->url.'/tentativas', $this->f['proposal'])->assertSessionHasErrors('recommendation');
$this->actingAs($this->f['head'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência de tentativa anterior'])->assertSessionHasErrors('recommendation');
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
});
test('recommendation rejects lower score incomplete matrix and stale admission', function (string $case) {
if ($case === 'incomplete') {
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][0]->id)->where('evaluator_id', $this->f['analyst']->id)->delete();
} elseif ($case === 'admission') {
$old = AdmissionDecision::query()->where('quotation_id', $this->f['quotes'][0]->id)->latest('id')->firstOrFail();
app(FormalAdmission::class)->propose($this->f['buyer'], $this->f['r'], ['quotation_id' => $old->quotation_id, 'previous_id' => $old->id, 'basis_hash' => $old->basis_hash, 'outcome' => 'admit', 'reason' => 'Nova tentativa de admissão sem confirmação']);
}
$data = [...$this->f['proposal'], 'basis_hash' => app(FormalRecommendation::class)->basis($this->f['r'])['hash']];
if ($case === 'lower') {
$data['quotation_id'] = $this->f['quotes'][2]->id;
}
$this->post($this->url.'/tentativas', $data)->assertSessionHasErrors('recommendation');
expect(RecommendationAttempt::query()->count())->toBe(0);
})->with(['lower', 'incomplete', 'admission']);
test('tie permits reasoned recommendation and award only of the chosen tied proposal', function () {
// Only evaluations change, preserving documentary admission basis.
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][0]->id)->update(['quality' => '0', 'history' => '0', 'payment' => '0']);
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][1]->id)->update(['quality' => '5', 'history' => '5', 'payment' => '2.5']);
// Existing weights yield 5.50 for each of the first two proposals, without changing received prices.
$this->f['quotes'][2]->evaluations()->update(['quality' => '0', 'history' => '0', 'payment' => '0']);
$basis = app(FormalRecommendation::class)->basis($this->f['r']);
expect($basis['ready'])->toBeTrue()->and($basis['top_ids'])->toHaveCount(2)->toContain($this->f['quotes'][0]->id, $this->f['quotes'][1]->id);
$attempt = recommendationAttempt($this, ['basis_hash' => $basis['hash'], 'quotation_id' => $this->f['quotes'][1]->id, 'reason' => 'Escolha fundamentada da segunda proposta empatada']);
recommendationConfer($this, $attempt);
recommendationConfer($this, $attempt, $this->f['other_reviewer']);
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
$this->post($this->awardUrl, [...$this->f['selection'], 'quotation_id' => $this->f['quotes'][1]->id])->assertSessionHasNoErrors();
expect(PurchaseAward::query()->sole()->quotation_id)->toBe($this->f['quotes'][1]->id);
});
test('changed matrix invalidates pending reviews and blocks emission after award', function () {
$attempt = recommendationAttempt($this);
recommendationConfer($this, $attempt);
recommendationConfer($this, $attempt, $this->f['head']);
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasNoErrors();
$award = PurchaseAward::query()->sole();
$this->actingAs($this->f['head'])->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][2]->id)->update(['comment' => 'Análise alterada antes da emissão']);
$this->actingAs($this->f['buyer'])->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('recommendation');
expect(PurchaseOrder::query()->count())->toBe(0);
});
test('changed evaluation requires a new recommendation with no inherited approvals', function () {
$attempt = recommendationAttempt($this);
recommendationConfer($this, $attempt);
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][1]->id)->update(['comment' => 'Nova fundamentação da avaliação']);
$this->actingAs($this->f['other_reviewer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência sobre uma base anterior'])->assertSessionHasErrors('recommendation');
$next = recommendationAttempt($this, ['previous_id' => $attempt->id, 'basis_hash' => app(FormalRecommendation::class)->basis($this->f['r'])['hash']]);
expect($next->revision)->toBe(2)->and(RecommendationReview::query()->where('recommendation_attempt_id', $next->id)->count())->toBe(0)->and(RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->count())->toBe(1);
});
test('new confirmed admission invalidates recommendation even with identical scores and prices', function () {
$attempt = recommendationAttempt($this);
recommendationConfer($this, $attempt);
recommendationConfer($this, $attempt, $this->f['other_reviewer']);
$before = app(ProposalComparison::class)->inspect($this->f['r']);
$this->actingAs($this->f['buyer']);
$admission = app(FormalAdmission::class);
$old = AdmissionDecision::query()->where('quotation_id', $this->f['quotes'][1]->id)->latest('id')->firstOrFail();
$new = $admission->propose($this->f['buyer'], $this->f['r'], ['quotation_id' => $old->quotation_id, 'previous_id' => $old->id, 'basis_hash' => $old->basis_hash, 'outcome' => 'admit', 'reason' => 'Reapreciação de admissão fundamentada']);
$this->actingAs($this->f['analyst']);
$admission->review($this->f['analyst'], $new, 'approved', 'Nova conferência de admissão fictícia');
expect(app(ProposalComparison::class)->inspect($this->f['r']))->toBe($before);
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
expect(PurchaseAward::query()->count())->toBe(0);
});
test('one identity cannot count again after role change and review audit failure rolls back', function () {
$attempt = recommendationAttempt($this);
recommendationConfer($this, $attempt);
$this->f['reviewer']->forceFill(['purchasing_role' => PurchasingRole::DepartmentHead, 'department_id' => $this->f['r']->department_id])->save();
recommendationConfer($this, $attempt, $this->f['reviewer']->fresh());
expect(RecommendationReview::query()->count())->toBe(1)->and(RecommendationReview::query()->sole()->snapshot['purchasing_role'])->toBe('finance');
$this->actingAs($this->f['head']);
DB::statement("CREATE TRIGGER fail_recommendation_review BEFORE INSERT ON audit_logs WHEN NEW.action = 'recommendation.reviewed' BEGIN SELECT RAISE(ABORT, 'test audit failure'); END");
try {
app(FormalRecommendation::class)->review($this->f['head'], $attempt, 'approved', 'Confirmação que deve ser revertida');
$this->fail('Missing conference rollback');
} catch (QueryException) {
expect(RecommendationReview::query()->count())->toBe(1)->and(app(FormalRecommendation::class)->status($attempt, $this->f['adoption']))->toBe('pending');
} finally {
DB::statement('DROP TRIGGER fail_recommendation_review');
}
$other = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outra chefia']);
$this->f['reviewer']->forceFill(['department_id' => $other->id])->save();
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferente anterior mudou de departamento'])->assertSessionHasErrors('recommendation');
});
test('current and earlier reviewer eligibility is revalidated while completed history remains', function () {
$attempt = recommendationAttempt($this);
recommendationConfer($this, $attempt);
$this->f['reviewer']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
$this->actingAs($this->f['other_reviewer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Primeiro conferente perdeu o perfil'])->assertSessionHasErrors('recommendation');
$this->f['other_reviewer']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'returned', 'reason' => 'Perfil revogado durante a sessão'])->assertForbidden();
$this->f['reviewer']->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
recommendationConfer($this, $attempt, $this->f['head']);
$this->f['reviewer']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
expect(app(FormalRecommendation::class)->selection($this->f['r'], $attempt->quotation_id)['review_ids'])->toHaveCount(2);
});
test('proponent requester and wrong department cannot confirm recommendations', function () {
$attempt = recommendationAttempt($this);
$this->f['buyer']->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
$this->actingAs($this->f['buyer']->fresh())->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Auto confirmação indevida'])->assertForbidden();
$this->f['issuer']->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
$this->actingAs($this->f['issuer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Solicitante não pode conferir'])->assertForbidden();
$other = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Departamento distinto']);
$this->f['head']->forceFill(['department_id' => $other->id])->save();
$this->actingAs($this->f['head'])->get($this->url)->assertNotFound();
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Chefia de outro departamento'])->assertNotFound();
expect(RecommendationReview::query()->count())->toBe(0);
});
test('policy changes preserve adoption until explicit new version and reset recommendations', function () {
$attempt = recommendationAttempt($this);
recommendationConfer($this, $attempt);
$workflow = app(FormalRecommendation::class);
$this->actingAs($this->f['admin']);
$disabled = $workflow->policy($this->f['admin'], [...$this->f['policy_data'], 'previous_id' => $this->f['policy']->id, 'required_count' => 1, 'enabled' => false]);
$this->actingAs($this->f['buyer'])->post($this->url.'/adoptar', ['previous_id' => $this->f['adoption']->id, 'policy_id' => $disabled->id, 'reason' => 'Adopção com política desactivada'])->assertSessionHasErrors('recommendation');
recommendationConfer($this, $attempt, $this->f['other_reviewer']);
expect($workflow->status($attempt, $this->f['adoption']))->toBe('confirmed')->and($this->f['adoption']->refresh()->snapshot['required_count'])->toBe(2);
$this->actingAs($this->f['admin']);
$new = $workflow->policy($this->f['admin'], [...$this->f['policy_data'], 'previous_id' => $disabled->id, 'required_count' => 1]);
$this->actingAs($this->f['buyer']);
$workflow->adopt($this->f['buyer'], $this->f['r'], $new->id, $this->f['adoption']->id, 'Outra versão expressamente adoptada');
$this->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
expect(RecommendationAttempt::query()->count())->toBe(1);
});
test('policy is admin only explicit and independent from admission and absent adoption keeps legacy circuit', function () {
$this->post('/politica-recomendacao', $this->f['policy_data'])->assertForbidden();
$this->actingAs($this->f['admin'])->post('/politica-recomendacao', ['previous_id' => $this->f['policy']->id, 'enabled' => true, 'roles' => [], 'reason' => 'Sem valores definidos pela empresa'])->assertSessionHasErrors(['roles', 'required_count']);
$this->post('/politica-recomendacao', $this->f['policy_data'])->assertSessionHasErrors('recommendation');
$this->get('/politica-recomendacao')->assertInertia(fn (Assert $p) => $p->component('purchases/recommendation-policy')->has('policies.data', 1));
// Remove only synthetic adoption from the isolated test DB to exercise the legacy path.
DB::table('recommendation_adoptions')->delete();
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasNoErrors();
$award = PurchaseAward::query()->sole();
expect($award->recommendation_snapshot)->toBeNull();
$this->post($this->url.'/adoptar', ['policy_id' => $this->f['policy']->id, 'previous_id' => 0, 'reason' => 'Adopção retroactiva impedida'])->assertSessionHasErrors('recommendation');
});
test('recommendation requires prior formal admission adoption and no implicit policy', function () {
// All fixture rows belong only to this in-memory database.
DB::table('recommendation_adoptions')->delete();
DB::table('admission_reviews')->delete();
DB::table('admission_decisions')->delete();
DB::table('admission_adoptions')->delete();
$this->post($this->url.'/adoptar', ['policy_id' => $this->f['policy']->id, 'previous_id' => 0, 'reason' => 'Sem adopção formal de admissão'])->assertSessionHasErrors('recommendation');
expect(RecommendationAdoption::query()->count())->toBe(0)->and(RecommendationPolicy::query()->count())->toBe(1);
});
test('report and history are restricted read only preserve old basis and escape user text', function () {
$attempt = recommendationAttempt($this, ['reason' => 'Fundamento fictício']);
recommendationConfer($this, $attempt, null, 'returned');
$next = recommendationAttempt($this, ['previous_id' => $attempt->id]);
$before = AuditLog::query()->count();
$this->get($this->url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $p) => $p->component('purchases/recommendation')->where('history.data.0.id', $next->id)->where('history.data.1.status', 'returned')->where('basis.ready', true));
$this->get('/recomendacoes/'.$next->id.'/imprimir')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertSee('Sistema de Gestão de Contratações (SGC)')->assertSee('Histórico desta adopção')->assertSee('<script>', false)->assertDontSee('