102 lines
5.5 KiB
PHP
102 lines
5.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Models\Budget;
|
|
use App\Models\BudgetReinforcement;
|
|
use App\Models\User;
|
|
use Brick\Math\BigDecimal;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class BudgetReinforcementWorkflow
|
|
{
|
|
private function actor(User $user, int $tenantId): void
|
|
{
|
|
abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
|
|
}
|
|
|
|
private function valid(Budget $budget, int $amount): void
|
|
{
|
|
if (! $budget->approved_by || (int) $budget->year !== now()->year || $amount <= 0) {
|
|
throw ValidationException::withMessages(['reinforcement' => 'Exige dotação aprovada do exercício actual e montante positivo.']);
|
|
}
|
|
// Same technical monetary ceiling as requisitions; not a company approval threshold.
|
|
if (BigDecimal::of($budget->approvedTotalMinor())->plus($amount)->isGreaterThan('100000000000000')) {
|
|
throw ValidationException::withMessages(['reinforcement' => 'A dotação acumulada ultrapassa o limite técnico de valores suportados.']);
|
|
}
|
|
}
|
|
|
|
/** @param array<string,mixed> $data */
|
|
public function propose(User $user, Budget $budget, array $data): BudgetReinforcement
|
|
{
|
|
$this->actor($user, $budget->tenant_id);
|
|
|
|
return DB::transaction(function () use ($user, $budget, $data) {
|
|
app(ConflictGuard::class)->lockCompany($user);
|
|
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
|
|
$this->actor($user, $budget->tenant_id);
|
|
$budget = Budget::query()->lockForUpdate()->findOrFail($budget->id);
|
|
$amount = BigDecimal::of((string) $data['amount'])->multipliedBy(100)->toScale(0)->toInt();
|
|
$reference = Str::upper(Str::squish($data['reference']));
|
|
$reason = trim($data['reason']);
|
|
$hash = hash('sha256', json_encode(['budget_id' => $budget->id, 'amount_minor' => $amount, 'reason' => $reason], JSON_THROW_ON_ERROR));
|
|
$existing = BudgetReinforcement::query()->where('reference', $reference)->first();
|
|
if ($existing) {
|
|
if ($existing->created_by !== $user->id || $existing->payload_hash !== $hash) {
|
|
throw ValidationException::withMessages(['reference' => 'Referência já registada com outros dados.']);
|
|
}
|
|
|
|
return $existing;
|
|
}
|
|
$this->valid($budget, $amount);
|
|
if ($reference === '' || $reason === '') {
|
|
throw ValidationException::withMessages(['reinforcement' => 'Referência e fundamento obrigatórios.']);
|
|
}
|
|
$reinforcement = BudgetReinforcement::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'created_by' => $user->id, 'reference' => $reference, 'payload_hash' => $hash, 'amount_minor' => $amount, 'reason' => $reason]);
|
|
$this->audit($user, $reinforcement, 'budget.reinforcement_proposed');
|
|
|
|
return $reinforcement;
|
|
});
|
|
}
|
|
|
|
public function review(User $user, BudgetReinforcement $reinforcement, string $decision, string $reason): void
|
|
{
|
|
$this->actor($user, $reinforcement->tenant_id);
|
|
abort_if($user->id === $reinforcement->created_by, 403);
|
|
if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') {
|
|
throw ValidationException::withMessages(['reinforcement' => 'Decisão e fundamento obrigatórios.']);
|
|
}
|
|
DB::transaction(function () use ($user, $reinforcement, $decision, $reason) {
|
|
app(ConflictGuard::class)->lockCompany($user);
|
|
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
|
|
$this->actor($user, $reinforcement->tenant_id);
|
|
$budget = Budget::query()->lockForUpdate()->findOrFail($reinforcement->budget_id);
|
|
$reinforcement = BudgetReinforcement::query()->lockForUpdate()->findOrFail($reinforcement->id);
|
|
if ($reinforcement->status !== 'pending') {
|
|
if ($reinforcement->status !== $decision) {
|
|
throw ValidationException::withMessages(['reinforcement' => 'Decisão final imutável.']);
|
|
}
|
|
|
|
return;
|
|
}
|
|
if ($decision === 'approved') {
|
|
$this->valid($budget, $reinforcement->amount_minor);
|
|
$author = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->find($reinforcement->created_by);
|
|
if (! $author || ! in_array($author->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance])) {
|
|
throw ValidationException::withMessages(['reinforcement' => 'Autor da proposta deixou de ser elegível.']);
|
|
}
|
|
}
|
|
$reinforcement->update(['status' => $decision, 'reviewed_by' => $user->id, 'review_reason' => trim($reason), 'reviewed_at' => now()]);
|
|
$this->audit($user, $reinforcement, 'budget.reinforcement_'.$decision);
|
|
});
|
|
}
|
|
|
|
private function audit(User $user, BudgetReinforcement $reinforcement, string $action): void
|
|
{
|
|
app(RequisitionWorkflow::class)->audit($user, $action, null, null, ['reinforcement_id' => $reinforcement->id, 'budget_id' => $reinforcement->budget_id, 'amount_minor' => $reinforcement->amount_minor, 'status' => $reinforcement->status]);
|
|
}
|
|
}
|