123 lines
7.7 KiB
PHP
123 lines
7.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Models\Budget;
|
|
use App\Models\BudgetEntry;
|
|
use App\Models\InvestmentPolicy;
|
|
use App\Models\InvestmentPolicyReview;
|
|
use App\Models\Quotation;
|
|
use App\Models\Requisition;
|
|
use App\Models\TechnicalReview;
|
|
use App\Models\User;
|
|
use Brick\Math\BigDecimal;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class InvestmentWorkflow
|
|
{
|
|
private function admin(User $user): void
|
|
{
|
|
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Admin, 403);
|
|
}
|
|
|
|
/** @param array<string,mixed> $data */
|
|
public function propose(User $user, array $data): InvestmentPolicy
|
|
{
|
|
$this->admin($user);
|
|
|
|
return DB::transaction(function () use ($user, $data) {
|
|
app(ConflictGuard::class)->lockCompany($user);
|
|
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
|
|
$this->admin($user);
|
|
$latest = InvestmentPolicy::query()->latest('revision')->first();
|
|
if ((int) $data['previous_id'] !== ($latest->id ?? 0) || $data['mode'] !== 'ceiling' || $data['scope'] !== 'process_total' || $data['currency'] !== 'MZN') {
|
|
throw ValidationException::withMessages(['investment' => 'Versão desactualizada ou regra não suportada. Não se presume um circuito de excepção.']);
|
|
}
|
|
$policy = InvestmentPolicy::query()->create(['tenant_id' => $user->tenant_id, 'created_by' => $user->id, 'revision' => ($latest->revision ?? 0) + 1, 'mode' => $data['mode'], 'scope' => $data['scope'], 'currency' => $data['currency'], 'ceiling_minor' => BigDecimal::of((string) $data['ceiling'])->multipliedBy(100)->toScale(0)->toInt(), 'includes_tax' => $data['includes_tax'], 'valid_from' => $data['valid_from'], 'valid_until' => $data['valid_until'], 'reason' => $data['reason']]);
|
|
app(RequisitionWorkflow::class)->audit($user, 'investment.proposed', null, null, $policy->toArray());
|
|
|
|
return $policy;
|
|
});
|
|
}
|
|
|
|
public function review(User $user, InvestmentPolicy $policy, string $decision, string $reason): void
|
|
{
|
|
$this->admin($user);
|
|
abort_unless($policy->tenant_id === $user->tenant_id && $policy->created_by !== $user->id, 403);
|
|
if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') {
|
|
throw ValidationException::withMessages(['investment' => 'Decisão e fundamento obrigatórios.']);
|
|
}
|
|
DB::transaction(function () use ($user, $policy, $decision, $reason) {
|
|
app(ConflictGuard::class)->lockCompany($user);
|
|
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
|
|
$this->admin($user);
|
|
$policy = InvestmentPolicy::query()->lockForUpdate()->findOrFail($policy->id);
|
|
$existing = InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->where('reviewed_by', $user->id)->first();
|
|
if ($existing) {
|
|
if ($existing->decision !== $decision || $existing->reason !== $reason) {
|
|
throw ValidationException::withMessages(['investment' => 'Decisão anterior imutável.']);
|
|
}
|
|
|
|
return;
|
|
}
|
|
if ($policy->status !== 'pending' || InvestmentPolicy::query()->max('revision') !== $policy->revision) {
|
|
throw ValidationException::withMessages(['investment' => 'Versão decidida ou substituída por proposta mais recente.']);
|
|
}
|
|
if ($decision === 'approved') {
|
|
$ids = InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique();
|
|
$actors = User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get();
|
|
if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => $actor->purchasing_role !== PurchasingRole::Admin)) {
|
|
throw ValidationException::withMessages(['investment' => 'Proponente e validadores devem continuar elegíveis até à publicação.']);
|
|
}
|
|
}
|
|
InvestmentPolicyReview::query()->create(['tenant_id' => $user->tenant_id, 'investment_policy_id' => $policy->id, 'reviewed_by' => $user->id, 'decision' => $decision, 'reason' => $reason, 'created_at' => now()]);
|
|
if ($decision === 'rejected') {
|
|
$policy->update(['status' => 'rejected', 'decided_at' => now()]);
|
|
} elseif (InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->where('decision', 'approved')->count() === 2) {
|
|
$policy->update(['status' => 'approved', 'decided_at' => now()]);
|
|
}
|
|
app(RequisitionWorkflow::class)->audit($user, 'investment.'.$decision, null, null, ['policy_id' => $policy->id, 'status' => $policy->status]);
|
|
});
|
|
}
|
|
|
|
public function current(): ?InvestmentPolicy
|
|
{
|
|
return InvestmentPolicy::query()->where('status', 'approved')->where('valid_from', '<=', today()->format('Y-m-d'))->latest('revision')->first();
|
|
}
|
|
|
|
public function requireCurrent(): InvestmentPolicy
|
|
{
|
|
$policy = $this->current();
|
|
if (! $policy || $policy->valid_until < today()->format('Y-m-d') || $policy->mode !== 'ceiling' || $policy->scope !== 'process_total' || $policy->currency !== 'MZN') {
|
|
throw ValidationException::withMessages(['purchase_type' => 'É necessária regra de investimento própria, com duas aprovações administrativas e vigência válida. Sem regra aplicável, adjudicação e emissão de equipamento bloqueadas.']);
|
|
}
|
|
|
|
return $policy;
|
|
}
|
|
|
|
/** @return array<string,mixed>|null */
|
|
public function check(Requisition $request, Quotation $quotation): ?array
|
|
{
|
|
if (! $request->items()->where('purchase_type', 'equipment')->exists()) {
|
|
return null;
|
|
}
|
|
$policy = $this->requireCurrent();
|
|
$amount = $policy->includes_tax ? $quotation->total_minor : $quotation->subtotal_minor;
|
|
if ($amount > $policy->ceiling_minor || $quotation->currency !== 'MZN') {
|
|
throw ValidationException::withMessages(['investment' => 'O total do processo excede o tecto de investimento da empresa ou usa moeda não suportada. Não há dispensa automática.']);
|
|
}
|
|
app(EquipmentWorkflow::class)->assertTechnical($request);
|
|
$entry = BudgetEntry::query()->where('requisition_id', $request->id)->where('kind', 'reserve')->first();
|
|
$budget = $entry ? Budget::query()->lockForUpdate()->find($entry->budget_id) : null;
|
|
if (! $budget || ! $budget->approved_by || $budget->kind !== 'investment' || $budget->cost_center_id !== $request->cost_center_id || (int) $budget->year !== now()->year) {
|
|
throw ValidationException::withMessages(['investment' => 'Exige reserva em dotação de investimento aprovada do centro e exercício. Não se atribui orçamento retroactivo.']);
|
|
}
|
|
$basis = app(EquipmentWorkflow::class)->basis($request);
|
|
$technical = TechnicalReview::query()->where('requisition_id', $request->id)->where('basis_hash', $basis)->where('decision', 'approved')->firstOrFail();
|
|
|
|
return [...$policy->only(['id', 'revision', 'mode', 'scope', 'currency', 'ceiling_minor', 'includes_tax', 'valid_from', 'valid_until']), 'reviewer_ids' => InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->orderBy('reviewed_by')->pluck('reviewed_by')->all(), 'budget_id' => $budget->id, 'technical_review_id' => $technical->id, 'technical_basis_hash' => $basis];
|
|
}
|
|
}
|