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 Compras (SGC)')->assertSee('Histórico desta adopção')->assertSee('<script>', false)->assertDontSee('