Implementa reforcos orcamentais com aprovacao independente
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
|
||||
Reference in New Issue
Block a user