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