Implementa admissão formal por política e adopção do processo
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AdmissionAdoption;
|
||||
use App\Models\AdmissionDecision;
|
||||
use App\Models\AdmissionPolicy;
|
||||
use App\Models\AdmissionReview;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\Requisition;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\FormalAdmission;
|
||||
use App\Services\ProposalOpeningWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class FormalAdmissionController 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/admission-policy', ['policies' => AdmissionPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => AdmissionPolicy::query()->latest('revision')->first()])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function configure(Request $request, FormalAdmission $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('admissions.policy');
|
||||
}
|
||||
|
||||
public function show(Request $request, Requisition $requisition, FormalAdmission $workflow, ProposalOpeningWorkflow $opening): Response
|
||||
{
|
||||
$requisition = $this->access($request, $requisition->id);
|
||||
$filter = $request->validate(['version' => 'nullable|integer|min:1']);
|
||||
$adoption = $workflow->adoption($requisition);
|
||||
$selected = isset($filter['version']) ? AdmissionAdoption::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filter['version']) : $adoption;
|
||||
$current = $selected && $selected->id === $adoption?->id;
|
||||
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
|
||||
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
|
||||
$decisions = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->latest('id')->get();
|
||||
$latestIds = $decisions->unique('quotation_id')->pluck('id');
|
||||
$proposals = collect($opening->proposals($requisition))->map(function ($quote) use ($workflow, $requisition, $decisions) {
|
||||
$basis = $workflow->basis($requisition, $quote['id']);
|
||||
|
||||
return [...$quote, 'basisHash' => $basis['hash'] ?? null, 'clear' => $basis['clear'] ?? false, 'previousId' => $decisions->firstWhere('quotation_id', $quote['id'])->id ?? 0];
|
||||
});
|
||||
$history = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->latest('id')->paginate(15)->withQueryString();
|
||||
$history->through(function ($decision) use ($workflow, $selected, $current, $latestIds, $requisition, $open, $request) {
|
||||
$basis = $workflow->basis($requisition, $decision->quotation_id);
|
||||
$fresh = $current && $latestIds->contains($decision->id) && $basis && $basis['hash'] === $decision->basis_hash;
|
||||
|
||||
return [...$decision->toArray(), 'status' => $workflow->status($decision, $selected), 'current' => (bool) $fresh,
|
||||
'reviews' => AdmissionReview::query()->where('admission_decision_id', $decision->id)->orderBy('id')->get(),
|
||||
'canReview' => $open && $fresh && $workflow->status($decision, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $decision) && ! AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $request->user()->id)->exists()];
|
||||
});
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('purchases/admission', ['requisition' => $requisition->only(['id', 'number']), 'policy' => AdmissionPolicy::query()->latest('revision')->first(), 'adoptions' => AdmissionAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'adoption' => $adoption, 'selected' => $selected, 'proposals' => $proposals, 'history' => $history, 'canAdopt' => $open && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canPropose' => $open && $current && $request->user()->purchasing_role === PurchasingRole::Procurement && $request->user()->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function adopt(Request $request, Requisition $requisition, FormalAdmission $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('admissions.show', $requisition);
|
||||
}
|
||||
|
||||
public function propose(Request $request, Requisition $requisition, FormalAdmission $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', 'outcome' => 'required|in:admit,exclude,clarify', '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('admissions.show', $requisition);
|
||||
}
|
||||
|
||||
public function review(Request $request, AdmissionDecision $decision, FormalAdmission $workflow): RedirectResponse
|
||||
{
|
||||
$adoption = AdmissionAdoption::query()->findOrFail($decision->admission_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(), $decision, $data['verdict'], $data['reason']);
|
||||
|
||||
return to_route('admissions.show', $adoption->requisition_id);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ class PurchaseProcessController extends Controller
|
||||
$documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]];
|
||||
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'];
|
||||
}
|
||||
$qualification = $purchases ? QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->first(['id', 'revision']) : null;
|
||||
if ($qualification) {
|
||||
@@ -111,7 +112,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, 'qualification.defined', 'qualification.assessed', 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
|
||||
$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'];
|
||||
}
|
||||
if ($inventory) {
|
||||
$actions[] = 'receipt.confirmed';
|
||||
@@ -126,7 +127,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' => ['Decisão formal de admissão ou exclusão', 'Relatório de recomendação em etapas'],
|
||||
'future' => ['Relatório de recomendação em etapas'],
|
||||
])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
|
||||
@@ -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 AdmissionAdoption extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array'];
|
||||
}
|
||||
}
|
||||
@@ -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 AdmissionDecision extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array'];
|
||||
}
|
||||
}
|
||||
@@ -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 AdmissionPolicy extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['roles' => 'array', 'enabled' => 'boolean'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AdmissionReview extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array'];
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
* @property array<string, mixed> $approval_snapshot
|
||||
* @property list<array<string, mixed>> $decision_matrix
|
||||
* @property array<string,mixed>|null $investment_snapshot
|
||||
* @property array<string,mixed>|null $admission_snapshot
|
||||
*/
|
||||
class PurchaseAward extends Model
|
||||
{
|
||||
@@ -26,7 +27,7 @@ class PurchaseAward extends Model
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'investment_snapshot' => 'array', 'delivery_date' => 'date:Y-m-d'];
|
||||
return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'investment_snapshot' => 'array', 'admission_snapshot' => 'array', 'delivery_date' => 'date:Y-m-d'];
|
||||
}
|
||||
|
||||
/** @return HasMany<PurchaseAwardApproval, $this> */
|
||||
|
||||
@@ -86,6 +86,7 @@ class ConflictGuard
|
||||
'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued',
|
||||
'opening.recorded',
|
||||
'qualification.defined', 'qualification.assessed',
|
||||
'admission.adopted', 'admission.proposed', 'admission.reviewed',
|
||||
'equipment.technical_approved', 'equipment.technical_rejected',
|
||||
'invoice.created', 'invoice.validated',
|
||||
'payment.registered', 'payment.confirmed', 'payment.rejected',
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AdmissionAdoption;
|
||||
use App\Models\AdmissionDecision;
|
||||
use App\Models\AdmissionPolicy;
|
||||
use App\Models\AdmissionReview;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\QualificationAssessment;
|
||||
use App\Models\QualificationSet;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\QuotationItem;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class FormalAdmission
|
||||
{
|
||||
private function invalid(string $message): never
|
||||
{
|
||||
throw ValidationException::withMessages(['admission' => $message]);
|
||||
}
|
||||
|
||||
public function adoption(Requisition $request): ?AdmissionAdoption
|
||||
{
|
||||
return AdmissionAdoption::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('O regime e as decisões só podem ser registados num pedido aprovado, antes da adjudicação.');
|
||||
}
|
||||
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();
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function policy(User $actor, array $data): AdmissionPolicy
|
||||
{
|
||||
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 expressamente perfis, quantidade de conferentes e fundamento da política.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($actor, $data) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin]);
|
||||
$latest = AdmissionPolicy::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 = AdmissionPolicy::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, 'admission.policy', null, null, $policy->toArray());
|
||||
|
||||
return $policy;
|
||||
});
|
||||
}
|
||||
|
||||
public function adopt(User $actor, Requisition $request, int $policyId, int $previousId, string $reason): AdmissionAdoption
|
||||
{
|
||||
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);
|
||||
$previous = $this->adoption($request);
|
||||
$policy = AdmissionPolicy::query()->latest('revision')->first();
|
||||
if (! $policy || ! $policy->enabled || $policy->id !== $policyId || ($previous->id ?? 0) !== $previousId) {
|
||||
$this->invalid('Seleccione a política activa e a adopção actual. Não se altera um regime implicitamente.');
|
||||
}
|
||||
if ($previous?->admission_policy_id === $policy->id || mb_strlen(trim($reason)) < 10) {
|
||||
$this->invalid('A adopção exige outra versão de política e fundamento próprio.');
|
||||
}
|
||||
$adoption = AdmissionAdoption::query()->create(['tenant_id' => $actor->tenant_id, 'requisition_id' => $request->id, 'admission_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, 'admission.adopted', $request, null, ['adoption' => $adoption->toArray(), 'previous_id' => $previous?->id]);
|
||||
|
||||
return $adoption;
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{set_id:int,payload:array<string,mixed>,hash:string,clear:bool}|null */
|
||||
public function basis(Requisition $request, int $quoteId): ?array
|
||||
{
|
||||
$set = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->first();
|
||||
if (! $set) {
|
||||
return null;
|
||||
}
|
||||
$opening = app(ProposalOpeningWorkflow::class);
|
||||
$quotes = $opening->proposals($request);
|
||||
if ($set->basis_hash !== $opening->basis($quotes)) {
|
||||
return null;
|
||||
}
|
||||
$quote = collect($quotes)->firstWhere('id', $quoteId);
|
||||
if (! $quote) {
|
||||
return null;
|
||||
}
|
||||
$ids = QualificationAssessment::query()->where('qualification_set_id', $set->id)->where('quotation_id', $quoteId)->selectRaw('MAX(id)')->groupBy('analyst_id');
|
||||
$records = QualificationAssessment::query()->whereIn('id', $ids)->orderBy('analyst_id')->get();
|
||||
$clear = $records->isNotEmpty();
|
||||
foreach ($set->criteria as $criterion) {
|
||||
$results = [];
|
||||
foreach ($records as $record) {
|
||||
$answer = collect($record->answers)->firstWhere('criterion_id', $criterion['id']);
|
||||
if (! $answer || ! in_array($answer['result'], ['met', 'not_met', 'not_applicable'], true) || ($criterion['required'] && $answer['result'] !== 'met')) {
|
||||
$clear = false;
|
||||
}
|
||||
if ($answer) {
|
||||
$results[] = $answer['result'];
|
||||
}
|
||||
}
|
||||
if (count(array_unique($results)) !== 1) {
|
||||
$clear = false;
|
||||
}
|
||||
}
|
||||
$payload = ['set_id' => $set->id, 'criteria' => $set->criteria, 'quotes_hash' => $set->basis_hash, 'quote' => $quote, 'analyses' => $records->map(fn ($record) => $record->only(['id', 'analyst_id', 'revision', 'answers', 'snapshot']))->all()];
|
||||
|
||||
return ['set_id' => $set->id, 'payload' => $payload, 'hash' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)), 'clear' => $clear];
|
||||
}
|
||||
|
||||
public function status(AdmissionDecision $decision, AdmissionAdoption $adoption): string
|
||||
{
|
||||
$reviews = AdmissionReview::query()->where('admission_decision_id', $decision->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): AdmissionDecision
|
||||
{
|
||||
if (! in_array($data['outcome'], ['admit', 'exclude', 'clarify'], true) || mb_strlen(trim($data['reason'])) < 10) {
|
||||
$this->invalid('Seleccione uma decisão e fundamente a tentativa.');
|
||||
}
|
||||
|
||||
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('O processo ainda não adoptou expressamente o regime.');
|
||||
}
|
||||
$quote = Quotation::query()->where('requisition_id', $request->id)->whereKey($data['quotation_id'])->firstOrFail();
|
||||
app(ConflictGuard::class)->assertProcess($request, $quote->supplier_id, $actor);
|
||||
$basis = $this->basis($request, $quote->id);
|
||||
if (! $basis || $basis['hash'] !== $data['basis_hash']) {
|
||||
$this->invalid('Confira requisitos e análises actuais antes de propor uma decisão.');
|
||||
}
|
||||
if ($data['outcome'] === 'admit' && ! $basis['clear']) {
|
||||
$this->invalid('A admissão exige análise sem pendências, divergências ou requisitos obrigatórios não resolvidos.');
|
||||
}
|
||||
$previous = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $quote->id)->latest('revision')->first();
|
||||
if ((int) $data['previous_id'] !== ($previous->id ?? 0)) {
|
||||
$this->invalid('A tentativa mudou. Actualize a página para preservar o antecedente correcto.');
|
||||
}
|
||||
$decision = AdmissionDecision::query()->create(['tenant_id' => $actor->tenant_id, 'admission_adoption_id' => $adoption->id, 'qualification_set_id' => $basis['set_id'], 'quotation_id' => $quote->id, 'created_by' => $actor->id, 'revision' => ($previous->revision ?? 0) + 1, 'outcome' => $data['outcome'], 'basis_hash' => $basis['hash'], 'snapshot' => ['basis' => $basis['payload'], 'proponent' => $actor->only(['id', 'name'])], 'reason' => $data['reason'], 'created_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'admission.proposed', $request, null, ['decision' => $decision->toArray(), 'previous_id' => $previous?->id]);
|
||||
|
||||
return $decision;
|
||||
});
|
||||
}
|
||||
|
||||
public function eligible(User $actor, Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): 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, $decision->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id);
|
||||
}
|
||||
|
||||
public function review(User $actor, AdmissionDecision $decision, string $verdict, string $reason): void
|
||||
{
|
||||
if (! in_array($verdict, ['approved', 'returned'], true) || mb_strlen(trim($reason)) < 10) {
|
||||
$this->invalid('Seleccione uma conferência válida e fundamente-a.');
|
||||
}
|
||||
DB::transaction(function () use ($actor, $decision, $verdict, $reason) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
|
||||
$original = AdmissionAdoption::query()->findOrFail($decision->admission_adoption_id);
|
||||
$request = $this->process($actor, $original->requisition_id);
|
||||
$adoption = $this->adoption($request);
|
||||
abort_unless($adoption && $this->eligible($actor, $request, $adoption, $decision), 403);
|
||||
$latest = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $decision->quotation_id)->latest('revision')->first();
|
||||
$basis = $this->basis($request, $decision->quotation_id);
|
||||
if ($decision->admission_adoption_id !== $adoption->id || $latest?->id !== $decision->id || ! $basis || $basis['hash'] !== $decision->basis_hash) {
|
||||
$this->invalid('A política do processo, tentativa ou base mudou. Registe outra tentativa e novas conferências.');
|
||||
}
|
||||
$existing = AdmissionReview::query()->where('admission_decision_id', $decision->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($decision, $adoption) !== 'pending') {
|
||||
$this->invalid('Esta tentativa já foi concluída ou devolvida.');
|
||||
}
|
||||
if ($verdict === 'approved') {
|
||||
foreach (AdmissionReview::query()->where('admission_decision_id', $decision->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, $decision)) {
|
||||
$this->invalid('Um conferente anterior deixou de ser elegível antes da conclusão. Devolva e prepare outra tentativa.');
|
||||
}
|
||||
}
|
||||
}
|
||||
$review = AdmissionReview::query()->create(['tenant_id' => $actor->tenant_id, 'admission_decision_id' => $decision->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, 'admission.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;
|
||||
}
|
||||
$decision = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $quoteId)->latest('revision')->first();
|
||||
$basis = $this->basis($request, $quoteId);
|
||||
if (! $decision || $decision->outcome !== 'admit' || ! $basis || ! $basis['clear'] || $basis['hash'] !== $decision->basis_hash || $this->status($decision, $adoption) !== 'confirmed') {
|
||||
$this->invalid('A proposta exige admissão confirmada na base actual do regime adoptado.');
|
||||
}
|
||||
|
||||
return ['adoption_id' => $adoption->id, 'policy_id' => $adoption->admission_policy_id, 'decision_id' => $decision->id, 'qualification_set_id' => $decision->qualification_set_id, 'basis_hash' => $decision->basis_hash, 'review_ids' => AdmissionReview::query()->where('admission_decision_id', $decision->id)->orderBy('id')->pluck('id')->all()];
|
||||
}
|
||||
|
||||
public function admitted(Requisition $request, int $quoteId): bool
|
||||
{
|
||||
try {
|
||||
$this->selection($request, $quoteId);
|
||||
|
||||
return true;
|
||||
} catch (ValidationException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ class ProcurementReviewReport
|
||||
'requisition' => [...$request->only(['id', 'number', 'status', 'justification']), 'department' => $request->department?->name, 'requester' => $request->requester?->name],
|
||||
'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']),
|
||||
'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']),
|
||||
|
||||
@@ -21,9 +21,13 @@ class ProposalComparison
|
||||
->with(['supplier', 'items' => fn ($q) => $q->orderBy('id'), 'evaluations' => fn ($q) => $q->orderBy('id')])->orderBy('id')->get();
|
||||
$guard = app(ConflictGuard::class);
|
||||
$referenceWeights = $quotes->first()?->weights;
|
||||
$adopted = app(FormalAdmission::class)->adoption($requisition) !== null;
|
||||
$rows = [];
|
||||
foreach ($quotes as $quote) {
|
||||
$reasons = [];
|
||||
if ($adopted && ! app(FormalAdmission::class)->admitted($requisition, $quote->id)) {
|
||||
$reasons[] = 'O regime adoptado exige admissão confirmada na base actual.';
|
||||
}
|
||||
if (! $quote->supplier?->eligible() || $quote->valid_until < today()) {
|
||||
$reasons[] = 'Fornecedor não elegível ou proposta expirada.';
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ class PurchaseWorkflow
|
||||
$this->sourceApproved($requisition, $rules);
|
||||
$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);
|
||||
$investment = app(InvestmentWorkflow::class)->check($requisition, $quote);
|
||||
app(BudgetWorkflow::class)->record($user, $requisition, 'award', $quote->total_minor, $quote->subtotal_minor);
|
||||
$award = PurchaseAward::query()->create([
|
||||
@@ -47,6 +48,7 @@ class PurchaseWorkflow
|
||||
'subtotal_minor' => $quote->subtotal_minor, 'tax_minor' => $quote->tax_minor, 'total_minor' => $quote->total_minor,
|
||||
'approval_snapshot' => $rules, 'decision_matrix' => $result['rows'], 'comparison_hash' => $result['fingerprint'],
|
||||
'investment_snapshot' => $investment,
|
||||
'admission_snapshot' => $admission,
|
||||
]);
|
||||
$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) {
|
||||
@@ -149,6 +151,7 @@ class PurchaseWorkflow
|
||||
'quotation' => [...$quote->only(['id', 'reference', 'currency', 'payment_terms', 'delivery_days']), 'valid_until' => $validUntil],
|
||||
'award' => [...$award->only(['id', 'justification', 'delivery_location']), 'delivery_date' => $deliveryDate],
|
||||
'issuer' => $user->only(['id', 'name']), 'approval_rules' => $award->approval_snapshot,
|
||||
'admission' => $award->admission_snapshot,
|
||||
'estimate_approvals' => $requisition->steps()->get()->toArray(),
|
||||
'final_approvals' => $award->approvals()->get()->toArray(),
|
||||
'decision_matrix' => $this->comparison->inspect($requisition)['rows'], 'items' => $lines,
|
||||
@@ -180,6 +183,9 @@ class PurchaseWorkflow
|
||||
throw ValidationException::withMessages(['investment' => 'A regra de investimento ou a base técnica/orçamental mudou. As aprovações anteriores não autorizam uma emissão com outra base.']);
|
||||
}
|
||||
$result = $this->checkComparison($user, $requisition, $quote);
|
||||
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 ($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.']);
|
||||
|
||||
Reference in New Issue
Block a user