Implementa reforcos orcamentais com aprovacao independente

This commit is contained in:
2026-10-28 15:03:49 +02:00
parent c41628d738
commit 153ba2c1af
24 changed files with 1023 additions and 25 deletions
+5 -2
View File
@@ -6,6 +6,7 @@ use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\BudgetConsumption;
use App\Models\BudgetEntry;
use App\Models\BudgetReinforcement;
use App\Models\CostCenter;
use App\Services\ConflictGuard;
use App\Services\RequisitionWorkflow;
@@ -34,8 +35,10 @@ class BudgetController extends Controller
$committed = (int) $entries->sum('committed_minor');
$consumed = (int) BudgetConsumption::query()->where('budget_id', $budget->id)->sum('amount_minor');
return [...$budget->toArray(), 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $budget->amount_minor - $reserved - $committed - $consumed];
})]);
$total = $budget->approvedTotalMinor();
return [...$budget->toArray(), 'reinforced_minor' => $total - $budget->amount_minor, 'approved_total_minor' => $total, 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $total - $reserved - $committed - $consumed];
}), 'reinforcements' => BudgetReinforcement::query()->latest('id')->paginate(20)]);
}
public function store(Request $request): RedirectResponse
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\BudgetReinforcement;
use App\Services\BudgetReinforcementWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class BudgetReinforcementController extends Controller
{
public function store(Request $request, Budget $budget, BudgetReinforcementWorkflow $workflow): RedirectResponse
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
$data = $request->validate(['reference' => 'required|string|max:100', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'reason' => 'required|string|max:4000']);
$workflow->propose($request->user(), $budget, $data);
return back();
}
public function review(Request $request, BudgetReinforcement $reinforcement, BudgetReinforcementWorkflow $workflow): RedirectResponse
{
$data = $request->validate(['decision' => 'required|in:approved,rejected', 'reason' => 'required|string|max:4000']);
$workflow->review($request->user(), $reinforcement, $data['decision'], $data['reason']);
return back();
}
}
+8
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Brick\Math\BigDecimal;
use Illuminate\Database\Eloquent\Model;
/** @property int $id
@@ -16,6 +17,13 @@ class Budget extends Model
protected $guarded = ['id'];
public function approvedTotalMinor(): int
{
$sum = BudgetReinforcement::query()->where('budget_id', $this->id)->where('status', 'approved')->sum('amount_minor');
return BigDecimal::of((string) $sum)->plus($this->amount_minor)->toInt();
}
protected static function booted(): void
{
static::updating(function (Budget $budget) {
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property int $id
* @property int $tenant_id
* @property int $budget_id
* @property int $created_by
* @property int $amount_minor
* @property string $status
* @property string $payload_hash
*/
class BudgetReinforcement extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $attributes = ['status' => 'pending'];
protected $hidden = ['payload_hash'];
protected static function booted(): void
{
static::updating(function (BudgetReinforcement $reinforcement) {
if ($reinforcement->getRawOriginal('status') !== 'pending' || array_diff(array_keys($reinforcement->getDirty()), ['status', 'reviewed_by', 'review_reason', 'reviewed_at', 'updated_at']) !== []) {
throw new \LogicException('Preservar o reforço e a decisão.');
}
});
static::deleting(fn () => throw new \LogicException('Não eliminar reforços orçamentais.'));
}
}
@@ -0,0 +1,101 @@
<?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]);
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ class BudgetWorkflow
$ledger = BudgetEntry::query()->where('budget_id', $budget->id);
$used = (int) (clone $ledger)->sum('reserved_minor') + (int) $ledger->sum('committed_minor');
$used += (int) BudgetConsumption::query()->where('budget_id', $budget->id)->sum('amount_minor');
if ($used + $reserveDelta + $commitDelta > $budget->amount_minor) {
if ($used + $reserveDelta + $commitDelta > $budget->approvedTotalMinor()) {
throw ValidationException::withMessages(['budget' => 'Saldo orçamental insuficiente. Não são permitidos excessos.']);
}
BudgetEntry::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'requisition_id' => $request->id, 'created_by' => $user->id, 'kind' => $kind, 'reserved_minor' => $reserveDelta, 'committed_minor' => $commitDelta, 'created_at' => now()]);