Files
compras/app/Services/BudgetReinforcementWorkflow.php
T

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]);
}
}