276 lines
21 KiB
PHP
276 lines
21 KiB
PHP
<?php
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Models\AdmissionDecision;
|
|
use App\Models\AuditLog;
|
|
use App\Models\BudgetEntry;
|
|
use App\Models\ConflictDeclaration;
|
|
use App\Models\Department;
|
|
use App\Models\ProposalEvaluation;
|
|
use App\Models\PurchaseAward;
|
|
use App\Models\PurchaseOrder;
|
|
use App\Models\RecommendationAdoption;
|
|
use App\Models\RecommendationAttempt;
|
|
use App\Models\RecommendationPolicy;
|
|
use App\Models\RecommendationReview;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\FormalAdmission;
|
|
use App\Services\FormalRecommendation;
|
|
use App\Services\ProposalComparison;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Inertia\Testing\AssertableInertia as Assert;
|
|
|
|
require_once __DIR__.'/../Support/RecommendationFixture.php';
|
|
|
|
beforeEach(function () {
|
|
$this->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' => '<script>alert("ficticio")</script>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('<script>alert', false);
|
|
$this->f['quotes'][0]->update(['reference' => 'Referência actual diferente']);
|
|
$this->get('/recomendacoes/'.$attempt->id.'/imprimir')->assertOk()->assertSee('Histórica ou base desactualizada')->assertSee('PROP-TEST')->assertDontSee('Referência actual diferente');
|
|
expect(AuditLog::query()->count())->toBe($before);
|
|
$this->f['issuer']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
|
|
foreach ([$this->f['issuer'], $this->f['receiver']] as $reader) {
|
|
$this->actingAs($reader)->get($this->url)->assertForbidden();
|
|
$this->get('/recomendacoes/'.$attempt->id.'/imprimir')->assertForbidden();
|
|
}
|
|
});
|
|
|
|
test('other tenant conflict and audit rollback cannot introduce recommendation decisions', function () {
|
|
$attempt = recommendationAttempt($this);
|
|
$tenant = Tenant::query()->create(['name' => 'Empresa fictícia distinta']);
|
|
$other = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Finance]);
|
|
$this->actingAs($other)->get($this->url)->assertNotFound();
|
|
$this->get('/recomendacoes/'.$attempt->id.'/imprimir')->assertNotFound();
|
|
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência de empresa distinta'])->assertNotFound();
|
|
$this->actingAs($this->f['buyer']);
|
|
DB::statement("CREATE TRIGGER fail_recommendation_audit BEFORE INSERT ON audit_logs WHEN NEW.action = 'recommendation.proposed' BEGIN SELECT RAISE(ABORT, 'test audit failure'); END");
|
|
try {
|
|
app(FormalRecommendation::class)->propose($this->f['buyer'], $this->f['r'], [...$this->f['proposal'], 'previous_id' => $attempt->id]);
|
|
$this->fail('Missing audit rollback');
|
|
} catch (QueryException) {
|
|
expect(RecommendationAttempt::query()->count())->toBe(1);
|
|
} finally {
|
|
DB::statement('DROP TRIGGER fail_recommendation_audit');
|
|
}
|
|
ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->f['reviewer']->id, 'requisition_id' => $this->f['r']->id, 'has_conflict' => true, 'known_since' => now()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício documentado']);
|
|
$this->actingAs($this->f['reviewer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência bloqueada por conflito'])->assertSessionHasErrors('conflict');
|
|
expect(RecommendationReview::query()->count())->toBe(0);
|
|
});
|