Implementa recomendação formal com adopção e conferência independente

This commit is contained in:
2026-10-28 20:49:30 +02:00
parent 29cd859385
commit ee17ff7764
33 changed files with 2233 additions and 37 deletions
@@ -0,0 +1,124 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\PurchaseAward;
use App\Models\RecommendationAdoption;
use App\Models\RecommendationAttempt;
use App\Models\RecommendationPolicy;
use App\Models\RecommendationReview;
use App\Models\Requisition;
use App\Models\User;
use App\Services\ConflictGuard;
use App\Services\FormalAdmission;
use App\Services\FormalRecommendation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class FormalRecommendationController extends Controller
{
private function access(Request $request, int $id): Requisition
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
}
public function policy(Request $request): Response
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
Inertia::encryptHistory();
$response = Inertia::render('purchases/recommendation-policy', ['policies' => RecommendationPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => RecommendationPolicy::query()->latest('revision')->first()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function configure(Request $request, FormalRecommendation $workflow): RedirectResponse
{
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'enabled' => 'required|boolean', 'roles' => 'required|array|min:1|max:2', 'roles.*' => 'required|distinct|in:finance,department_head', 'required_count' => 'required|integer|min:1|max:50', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited']);
$data['enabled'] = (bool) $data['enabled'];
$workflow->policy($request->user(), $data);
return to_route('recommendations.policy');
}
public function show(Request $request, Requisition $requisition, FormalRecommendation $workflow): Response
{
$requisition = $this->access($request, $requisition->id);
$filter = $request->validate(['version' => 'nullable|integer|min:1']);
$adoption = $workflow->adoption($requisition);
$selected = isset($filter['version']) ? RecommendationAdoption::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filter['version']) : $adoption;
$current = $selected && $selected->id === $adoption?->id;
$basis = $workflow->basis($requisition);
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->latest('revision')->first();
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
$history = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->latest('revision')->paginate(10)->withQueryString();
$history->through(function ($attempt) use ($workflow, $selected, $current, $latest, $basis, $requisition, $open, $request) {
$fresh = $current && $latest?->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash;
return [...$attempt->toArray(), 'status' => $workflow->status($attempt, $selected), 'current' => (bool) $fresh, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(),
'canReview' => $open && $fresh && $workflow->status($attempt, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $attempt) && ! RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $request->user()->id)->exists()];
});
Inertia::encryptHistory();
$response = Inertia::render('purchases/recommendation', ['requisition' => $requisition->only(['id', 'number']), 'policy' => RecommendationPolicy::query()->latest('revision')->first(), 'adoption' => $adoption, 'selected' => $selected, 'adoptions' => RecommendationAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'hasAdmission' => app(FormalAdmission::class)->adoption($requisition) !== null,
'basis' => ['hash' => $basis['hash'], 'ready' => $basis['ready'], 'top_ids' => $basis['top_ids'], 'rows' => collect($basis['payload']['rows'])->map(fn ($row) => array_intersect_key($row, array_flip(['id', 'reference', 'supplier_name', 'currency', 'total_minor', 'eligible', 'ineligibility_reasons', 'evaluation_count', 'score_minor'])))->all()], 'previousId' => $latest->id ?? 0,
'history' => $history, 'closed' => $closed, 'canAdopt' => $open && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canPropose' => $open && $current && $basis['ready'] && $request->user()->purchasing_role === PurchasingRole::Procurement && $request->user()->id !== $requisition->requester_id])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function adopt(Request $request, Requisition $requisition, FormalRecommendation $workflow): RedirectResponse
{
$this->access($request, $requisition->id);
$data = $request->validate(['policy_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'snapshot' => 'prohibited', 'created_by' => 'prohibited']);
$workflow->adopt($request->user(), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
return to_route('recommendations.show', $requisition);
}
public function propose(Request $request, Requisition $requisition, FormalRecommendation $workflow): RedirectResponse
{
$this->access($request, $requisition->id);
$data = $request->validate(['quotation_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited', 'status' => 'prohibited']);
$workflow->propose($request->user(), $requisition, $data);
return to_route('recommendations.show', $requisition);
}
public function review(Request $request, RecommendationAttempt $attempt, FormalRecommendation $workflow): RedirectResponse
{
$adoption = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id);
$this->access($request, $adoption->requisition_id);
$data = $request->validate(['verdict' => 'required|in:approved,returned', 'reason' => 'required|string|min:10|max:4000', 'reviewed_by' => 'prohibited', 'snapshot' => 'prohibited', 'tenant_id' => 'prohibited']);
$workflow->review($request->user(), $attempt, $data['verdict'], $data['reason']);
return to_route('recommendations.show', $adoption->requisition_id);
}
public function print(Request $request, RecommendationAttempt $attempt, FormalRecommendation $workflow): Response
{
return DB::transaction(function () use ($request, $attempt, $workflow) {
if (DB::connection()->getDriverName() === 'pgsql') {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
}
$actor = User::query()->where('tenant_id', $request->user()->tenant_id)->findOrFail($request->user()->id);
$request->setUserResolver(fn () => $actor);
$adoption = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id);
$requisition = $this->access($request, $adoption->requisition_id);
$basis = $workflow->basis($requisition);
$currentAdoption = $workflow->adoption($requisition);
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->firstOrFail();
$history = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->where('id', '<', $attempt->id)->orderBy('revision')->get()->map(fn ($previous) => ['attempt' => $previous, 'state' => $workflow->status($previous, $adoption), 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $previous->id)->orderBy('id')->get()]);
return response()->view('purchases.recommendation-print', ['requisition' => $requisition, 'attempt' => $attempt, 'adoption' => $adoption, 'history' => $history, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(), 'state' => $workflow->status($attempt, $adoption), 'current' => $currentAdoption?->id === $adoption->id && $latest->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash, 'generatedAt' => now()->timezone('Africa/Johannesburg'), 'generatedBy' => $request->user()->name], 200, ['Cache-Control' => 'private, no-store, max-age=0']);
});
}
}
+19 -1
View File
@@ -7,13 +7,16 @@ use App\Models\AuditLog;
use App\Models\AwardCancellation;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\RecommendationAttempt;
use App\Models\Requisition;
use App\Services\FormalRecommendation;
use App\Services\ProposalComparison;
use App\Services\PurchaseWorkflow;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -30,10 +33,25 @@ class PurchaseController extends Controller
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
$award = PurchaseAward::query()->where('requisition_id', $requisition->id)->with('approvals')->first();
$frozen = $award && AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists();
$recommendation = app(FormalRecommendation::class);
$adoption = $recommendation->adoption($requisition);
$candidate = null;
if ($adoption) {
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
if ($latest) {
try {
$recommendation->selection($requisition, $latest->quotation_id);
$candidate = $latest->quotation_id;
} catch (ValidationException) {
// The POST revalidates again; stale recommendations cannot enable the form.
}
}
}
return Inertia::render('purchases/award', [
'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $award,
'canSelect' => $request->user()->purchasing_role === PurchasingRole::Procurement && $award === null && $request->user()->id !== $requisition->requester_id,
'canSelect' => $request->user()->purchasing_role === PurchasingRole::Procurement && $award === null && $request->user()->id !== $requisition->requester_id && (! $adoption || $candidate !== null),
'recommendation' => ['adopted' => $adoption !== null, 'quotation_id' => $candidate],
'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement,
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
@@ -40,6 +40,7 @@ class PurchaseProcessController extends Controller
if ($purchases) {
$documents[] = ['label' => 'Relatório de conferência', 'kind' => 'Relatório de consulta', 'href' => $base.'/relatorio'];
$documents[] = ['label' => 'Regime, decisões e conferências', 'kind' => 'Admissão de propostas', 'href' => $base.'/admissao'];
$documents[] = ['label' => 'Regime, tentativas e conferências', 'kind' => 'Recomendação', 'href' => $base.'/recomendacao'];
}
$qualification = $purchases ? QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->first(['id', 'revision']) : null;
if ($qualification) {
@@ -112,7 +113,7 @@ class PurchaseProcessController extends Controller
}
$actions = ['requisition.created', 'requisition.updated', 'requisition.submitted', 'approval.approved', 'approval.rejected', 'equipment.technical_approved', 'equipment.technical_rejected', 'requisition.revision_created'];
if ($purchases) {
$actions = [...$actions, 'admission.adopted', 'admission.proposed', 'admission.reviewed', 'qualification.defined', 'qualification.assessed', 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
$actions = [...$actions, 'recommendation.adopted', 'recommendation.proposed', 'recommendation.reviewed', 'admission.adopted', 'admission.proposed', 'admission.reviewed', 'qualification.defined', 'qualification.assessed', 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
}
if ($inventory) {
$actions[] = 'receipt.confirmed';
@@ -127,7 +128,7 @@ class PurchaseProcessController extends Controller
'stages' => $stages, 'documents' => $documents, 'decisions' => $decisions, 'related' => $related,
'cancellation' => $cancellation ? ['status' => $cancellation->status, 'href' => '/revisao-compras'] : null,
'history' => AuditLog::query()->where('requisition_id', $requisition->id)->whereIn('action', $actions)->orderBy('id')->get(['id', 'action', 'created_at']),
'future' => ['Relatório de recomendação em etapas'],
'future' => [],
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+2 -1
View File
@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property list<array<string, mixed>> $decision_matrix
* @property array<string,mixed>|null $investment_snapshot
* @property array<string,mixed>|null $admission_snapshot
* @property array<string,mixed>|null $recommendation_snapshot
*/
class PurchaseAward extends Model
{
@@ -27,7 +28,7 @@ class PurchaseAward extends Model
protected function casts(): array
{
return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'investment_snapshot' => 'array', 'admission_snapshot' => 'array', 'delivery_date' => 'date:Y-m-d'];
return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'investment_snapshot' => 'array', 'admission_snapshot' => 'array', 'recommendation_snapshot' => 'array', 'delivery_date' => 'date:Y-m-d'];
}
/** @return HasMany<PurchaseAwardApproval, $this> */
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property array{roles:list<string>,required_count:int,policy_revision:int} $snapshot */
class RecommendationAdoption extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['snapshot' => 'array'];
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property array<string,mixed> $snapshot */
class RecommendationAttempt extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['snapshot' => 'array'];
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property list<string> $roles */
class RecommendationPolicy extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['roles' => 'array', 'enabled' => 'boolean'];
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class RecommendationReview extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['snapshot' => 'array'];
}
}
+1
View File
@@ -87,6 +87,7 @@ class ConflictGuard
'opening.recorded',
'qualification.defined', 'qualification.assessed',
'admission.adopted', 'admission.proposed', 'admission.reviewed',
'recommendation.adopted', 'recommendation.proposed', 'recommendation.reviewed',
'equipment.technical_approved', 'equipment.technical_rejected',
'invoice.created', 'invoice.validated',
'payment.registered', 'payment.confirmed', 'payment.rejected',
+214
View File
@@ -0,0 +1,214 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\AdmissionDecision;
use App\Models\AdmissionReview;
use App\Models\ProposalEvaluation;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\Quotation;
use App\Models\QuotationItem;
use App\Models\RecommendationAdoption;
use App\Models\RecommendationAttempt;
use App\Models\RecommendationPolicy;
use App\Models\RecommendationReview;
use App\Models\Requisition;
use App\Models\Supplier;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class FormalRecommendation
{
private function invalid(string $message): never
{
throw ValidationException::withMessages(['recommendation' => $message]);
}
public function adoption(Requisition $request): ?RecommendationAdoption
{
return RecommendationAdoption::query()->where('requisition_id', $request->id)->latest('revision')->first();
}
private function process(User $actor, int $id): Requisition
{
$request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id);
if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $id)->exists() || PurchaseOrder::query()->where('requisition_id', $id)->exists()) {
$this->invalid('A recomendação só pode ser preparada num pedido aprovado, antes de adjudicar.');
}
app(ConflictGuard::class)->assertParticipation($actor, $request);
$quotes = Quotation::query()->where('requisition_id', $id)->orderBy('id')->lockForUpdate()->get(['id', 'supplier_id']);
Supplier::query()->whereIn('id', $quotes->pluck('supplier_id'))->orderBy('id')->lockForUpdate()->get();
QuotationItem::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get();
ProposalEvaluation::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get();
return $request;
}
/** @param array<string,mixed> $data */
public function policy(User $actor, array $data): RecommendationPolicy
{
if (! is_bool($data['enabled']) || ! is_array($data['roles']) || $data['roles'] === [] || count(array_unique($data['roles'])) !== count($data['roles']) || array_diff($data['roles'], ['finance', 'department_head']) !== [] || filter_var($data['required_count'], FILTER_VALIDATE_INT) === false || $data['required_count'] < 1 || $data['required_count'] > 50 || mb_strlen(trim($data['reason'])) < 10) {
$this->invalid('Defina perfis, quantidade de conferentes e fundamento próprios da recomendação.');
}
return DB::transaction(function () use ($actor, $data) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin]);
$latest = RecommendationPolicy::query()->latest('revision')->first();
if ((int) $data['previous_id'] !== ($latest->id ?? 0)) {
$this->invalid('A política mudou. Actualize antes de criar outra versão.');
}
$policy = RecommendationPolicy::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'enabled' => $data['enabled'], 'roles' => array_values($data['roles']), 'required_count' => $data['required_count'], 'reason' => $data['reason'], 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'recommendation.policy', null, null, $policy->toArray());
return $policy;
});
}
public function adopt(User $actor, Requisition $request, int $policyId, int $previousId, string $reason): RecommendationAdoption
{
return DB::transaction(function () use ($actor, $request, $policyId, $previousId, $reason) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
$request = $this->process($actor, $request->id);
if (! app(FormalAdmission::class)->adoption($request)) {
$this->invalid('Adopte expressamente o regime de admissão antes da recomendação.');
}
$previous = $this->adoption($request);
$policy = RecommendationPolicy::query()->latest('revision')->first();
if (! $policy || ! $policy->enabled || $policy->id !== $policyId || ($previous->id ?? 0) !== $previousId || $previous?->recommendation_policy_id === $policy->id || mb_strlen(trim($reason)) < 10) {
$this->invalid('Seleccione a política activa, a adopção actual e um fundamento próprio.');
}
$adoption = RecommendationAdoption::query()->create(['tenant_id' => $actor->tenant_id, 'requisition_id' => $request->id, 'recommendation_policy_id' => $policy->id, 'created_by' => $actor->id, 'revision' => ($previous->revision ?? 0) + 1, 'snapshot' => ['roles' => $policy->roles, 'required_count' => $policy->required_count, 'policy_revision' => $policy->revision], 'reason' => $reason, 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'recommendation.adopted', $request, null, ['adoption' => $adoption->toArray(), 'previous_id' => $previous?->id]);
return $adoption;
});
}
/** @return array{hash:string,payload:array{admission_adoption:array<string,mixed>|null,comparison_hash:string,ready:bool,top_ids:list<int>,rows:list<array<string,mixed>>,admissions:list<array<string,mixed>>},ready:bool,top_ids:list<int>} */
public function basis(Requisition $request): array
{
$comparison = app(ProposalComparison::class)->inspect($request);
$admission = app(FormalAdmission::class);
$adoption = $admission->adoption($request);
$rows = [];
$admissions = [];
foreach ($comparison['rows'] as $row) {
// Freeze the decision inputs without supplier contacts or private documents.
$rows[] = [...array_intersect_key($row, array_flip(['id', 'supplier_id', 'reference', 'currency', 'subtotal_minor', 'tax_minor', 'total_minor', 'delivery_days', 'valid_until', 'payment_terms', 'weights', 'eligible', 'ineligibility_reasons', 'evaluation_count', 'score_minor', 'items', 'evaluations', 'qualified_evaluations'])), 'supplier_name' => $row['supplier']['name'] ?? 'Fornecedor indisponível'];
$decision = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id ?? 0)->where('quotation_id', $row['id'])->latest('revision')->first();
$admissions[] = ['quotation_id' => $row['id'], 'basis' => $admission->basis($request, $row['id']), 'decision' => $decision?->toArray(), 'reviews' => AdmissionReview::query()->where('admission_decision_id', $decision->id ?? 0)->orderBy('id')->get()->toArray()];
}
$payload = ['admission_adoption' => $adoption?->toArray(), 'comparison_hash' => $comparison['fingerprint'], 'ready' => $comparison['ready'], 'top_ids' => $comparison['top_ids'], 'rows' => $rows, 'admissions' => $admissions];
return ['hash' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)), 'payload' => $payload, 'ready' => $adoption !== null && $comparison['ready'], 'top_ids' => $comparison['top_ids']];
}
public function status(RecommendationAttempt $attempt, RecommendationAdoption $adoption): string
{
$reviews = RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->get();
if ($reviews->contains('verdict', 'returned')) {
return 'returned';
}
return $reviews->where('verdict', 'approved')->count() >= $adoption->snapshot['required_count'] ? 'confirmed' : 'pending';
}
/** @param array<string,mixed> $data */
public function propose(User $actor, Requisition $request, array $data): RecommendationAttempt
{
if (mb_strlen(trim($data['reason'])) < 10) {
$this->invalid('Fundamente a recomendação, incluindo a escolha em caso de empate.');
}
return DB::transaction(function () use ($actor, $request, $data) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
$request = $this->process($actor, $request->id);
abort_if($actor->id === $request->requester_id, 403);
$adoption = $this->adoption($request);
if (! $adoption) {
$this->invalid('Adopte expressamente o regime de recomendação.');
}
$quote = Quotation::query()->where('requisition_id', $request->id)->whereKey($data['quotation_id'])->firstOrFail();
$basis = $this->basis($request);
if (! $basis['ready'] || $basis['hash'] !== $data['basis_hash'] || ! in_array($quote->id, $basis['top_ids'], true)) {
$this->invalid('Exige-se matriz pronta, admissões confirmadas e escolha fundamentada entre as maiores pontuações da base actual.');
}
$previous = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
if ((int) $data['previous_id'] !== ($previous->id ?? 0)) {
$this->invalid('A tentativa mudou. Actualize antes de registar outra revisão.');
}
$attempt = RecommendationAttempt::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_adoption_id' => $adoption->id, 'quotation_id' => $quote->id, 'created_by' => $actor->id, 'revision' => ($previous->revision ?? 0) + 1, 'basis_hash' => $basis['hash'], 'snapshot' => ['basis' => $basis['payload'], 'proponent' => $actor->only(['id', 'name']), 'policy' => $adoption->snapshot], 'reason' => $data['reason'], 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'recommendation.proposed', $request, null, ['attempt' => $attempt->toArray(), 'previous_id' => $previous?->id]);
return $attempt;
});
}
public function eligible(User $actor, Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool
{
return $actor->tenant_id === $request->tenant_id && in_array($actor->purchasing_role->value, $adoption->snapshot['roles'], true) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id);
}
public function review(User $actor, RecommendationAttempt $attempt, string $verdict, string $reason): void
{
if (! in_array($verdict, ['approved', 'returned'], true) || mb_strlen(trim($reason)) < 10) {
$this->invalid('Registe uma conferência válida e fundamentada.');
}
DB::transaction(function () use ($actor, $attempt, $verdict, $reason) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
$original = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id);
$request = $this->process($actor, $original->requisition_id);
$adoption = $this->adoption($request);
abort_unless($adoption && $this->eligible($actor, $request, $adoption, $attempt), 403);
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
$basis = $this->basis($request);
if ($attempt->recommendation_adoption_id !== $adoption->id || $latest?->id !== $attempt->id || ! $basis['ready'] || $basis['hash'] !== $attempt->basis_hash) {
$this->invalid('A base, adopção ou tentativa mudou. Prepare outra recomendação e novas conferências.');
}
$existing = RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $actor->id)->first();
if ($existing) {
if ($existing->verdict !== $verdict || $existing->reason !== $reason) {
$this->invalid('A conferência anterior é imutável.');
}
return;
}
if ($this->status($attempt, $adoption) !== 'pending') {
$this->invalid('A tentativa já foi concluída ou devolvida.');
}
if ($verdict === 'approved') {
foreach (RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('verdict', 'approved')->get() as $review) {
$reviewer = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->find($review->reviewed_by);
if (! $reviewer || ! $this->eligible($reviewer, $request, $adoption, $attempt)) {
$this->invalid('Um conferente anterior deixou de ser elegível. Devolva e prepare outra tentativa.');
}
}
}
$review = RecommendationReview::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_attempt_id' => $attempt->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => $actor->only(['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'recommendation.reviewed', $request, null, $review->toArray());
});
}
/** @return array<string,mixed>|null */
public function selection(Requisition $request, int $quoteId): ?array
{
$adoption = $this->adoption($request);
if (! $adoption) {
return null;
}
$attempt = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
$basis = $this->basis($request);
if (! $attempt || $attempt->quotation_id !== $quoteId || ! $basis['ready'] || $basis['hash'] !== $attempt->basis_hash || $this->status($attempt, $adoption) !== 'confirmed') {
$this->invalid('A adjudicação exige a proposta da recomendação confirmada na base actual.');
}
return ['adoption_id' => $adoption->id, 'policy_id' => $adoption->recommendation_policy_id, 'attempt_id' => $attempt->id, 'quotation_id' => $quoteId, 'basis_hash' => $attempt->basis_hash, 'review_ids' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->pluck('id')->all()];
}
}
+1
View File
@@ -67,6 +67,7 @@ class ProcurementReviewReport
'comparison' => ['ready' => $comparison['ready'], 'top_count' => $comparison['ready'] ? count($comparison['top_ids']) : 0, 'rows' => $rows],
'qualification' => $qualification,
'admission' => app(FormalAdmission::class)->adoption($request)?->only(['id', 'revision', 'snapshot', 'reason']),
'recommendation' => app(FormalRecommendation::class)->adoption($request)?->only(['id', 'revision', 'snapshot', 'reason']),
'opening' => $opening ? [...$opening->only(['id', 'revision', 'reference']), 'source_changed' => $opening->basis_hash !== $basis] : null,
'award' => $award?->only(['id', 'status', 'total_minor']),
'order' => $order?->only(['id', 'number', 'currency', 'total_minor']),
+6
View File
@@ -39,6 +39,7 @@ class PurchaseWorkflow
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']);
$result = $this->checkComparison($user, $requisition, $quote);
$admission = app(FormalAdmission::class)->selection($requisition, $quote->id);
$recommendation = app(FormalRecommendation::class)->selection($requisition, $quote->id);
$investment = app(InvestmentWorkflow::class)->check($requisition, $quote);
app(BudgetWorkflow::class)->record($user, $requisition, 'award', $quote->total_minor, $quote->subtotal_minor);
$award = PurchaseAward::query()->create([
@@ -49,6 +50,7 @@ class PurchaseWorkflow
'approval_snapshot' => $rules, 'decision_matrix' => $result['rows'], 'comparison_hash' => $result['fingerprint'],
'investment_snapshot' => $investment,
'admission_snapshot' => $admission,
'recommendation_snapshot' => $recommendation,
]);
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $quote->total_minor : $quote->subtotal_minor, $requisition->items()->where('purchase_type', 'equipment')->exists());
foreach ($roles as $position => $role) {
@@ -152,6 +154,7 @@ class PurchaseWorkflow
'award' => [...$award->only(['id', 'justification', 'delivery_location']), 'delivery_date' => $deliveryDate],
'issuer' => $user->only(['id', 'name']), 'approval_rules' => $award->approval_snapshot,
'admission' => $award->admission_snapshot,
'recommendation' => $award->recommendation_snapshot,
'estimate_approvals' => $requisition->steps()->get()->toArray(),
'final_approvals' => $award->approvals()->get()->toArray(),
'decision_matrix' => $this->comparison->inspect($requisition)['rows'], 'items' => $lines,
@@ -186,6 +189,9 @@ class PurchaseWorkflow
if (app(FormalAdmission::class)->selection($requisition, $quote->id) !== $award->admission_snapshot) {
throw ValidationException::withMessages(['admission' => 'A base de admissão já não corresponde à adjudicação. Não se emite com outra base.']);
}
if (app(FormalRecommendation::class)->selection($requisition, $quote->id) !== $award->recommendation_snapshot) {
throw ValidationException::withMessages(['recommendation' => 'A recomendação já não corresponde à base da adjudicação. Prepare a revisão autorizada antes de emitir.']);
}
if ($result['fingerprint'] !== $award->comparison_hash || $award->approval_snapshot !== $rules ||
$quote->subtotal_minor !== $award->subtotal_minor || $quote->tax_minor !== $award->tax_minor || $quote->total_minor !== $award->total_minor) {
throw ValidationException::withMessages(['comparison' => 'A base da adjudicação mudou. As aprovações anteriores não autorizam a emissão com dados diferentes.']);