id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin]), 403); } /** @param array $data */ public function propose(User $user, array $data): InvestmentPolicy { $this->admin($user); return DB::transaction(function () use ($user, $data) { app(ConflictGuard::class)->lockCompany($user); $user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id); $this->admin($user); $latest = InvestmentPolicy::query()->latest('revision')->first(); if ((int) $data['previous_id'] !== ($latest->id ?? 0) || $data['mode'] !== 'ceiling' || $data['scope'] !== 'process_total' || $data['currency'] !== 'MZN') { throw ValidationException::withMessages(['investment' => 'Versão desactualizada ou regra não suportada. Não se presume um circuito de excepção.']); } $policy = InvestmentPolicy::query()->create(['tenant_id' => $user->tenant_id, 'created_by' => $user->id, 'revision' => ($latest->revision ?? 0) + 1, 'mode' => $data['mode'], 'scope' => $data['scope'], 'currency' => $data['currency'], 'ceiling_minor' => BigDecimal::of((string) $data['ceiling'])->multipliedBy(100)->toScale(0)->toInt(), 'includes_tax' => $data['includes_tax'], 'valid_from' => $data['valid_from'], 'valid_until' => $data['valid_until'], 'reason' => $data['reason']]); app(RequisitionWorkflow::class)->audit($user, 'investment.proposed', null, null, $policy->toArray()); return $policy; }); } public function review(User $user, InvestmentPolicy $policy, string $decision, string $reason): void { $this->admin($user); abort_unless($policy->tenant_id === $user->tenant_id && $policy->created_by !== $user->id, 403); if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') { throw ValidationException::withMessages(['investment' => 'Decisão e fundamento obrigatórios.']); } DB::transaction(function () use ($user, $policy, $decision, $reason) { app(ConflictGuard::class)->lockCompany($user); $user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id); $this->admin($user); $policy = InvestmentPolicy::query()->lockForUpdate()->findOrFail($policy->id); $existing = InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->where('reviewed_by', $user->id)->first(); if ($existing) { if ($existing->decision !== $decision || $existing->reason !== $reason) { throw ValidationException::withMessages(['investment' => 'Decisão anterior imutável.']); } return; } if ($policy->status !== 'pending' || InvestmentPolicy::query()->max('revision') !== $policy->revision) { throw ValidationException::withMessages(['investment' => 'Versão decidida ou substituída por proposta mais recente.']); } if ($decision === 'approved') { $ids = InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique(); $actors = User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get(); if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => ! $actor->hasFunctions([PurchasingRole::Admin]))) { throw ValidationException::withMessages(['investment' => 'Proponente e validadores devem continuar elegíveis até à publicação.']); } } InvestmentPolicyReview::query()->create(['tenant_id' => $user->tenant_id, 'investment_policy_id' => $policy->id, 'reviewed_by' => $user->id, 'decision' => $decision, 'reason' => $reason, 'created_at' => now()]); if ($decision === 'rejected') { $policy->update(['status' => 'rejected', 'decided_at' => now()]); } elseif (InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->where('decision', 'approved')->count() === 2) { $policy->update(['status' => 'approved', 'decided_at' => now()]); } app(RequisitionWorkflow::class)->audit($user, 'investment.'.$decision, null, null, ['policy_id' => $policy->id, 'status' => $policy->status]); }); } public function current(): ?InvestmentPolicy { return InvestmentPolicy::query()->where('status', 'approved')->where('valid_from', '<=', today()->format('Y-m-d'))->latest('revision')->first(); } public function requireCurrent(): InvestmentPolicy { $policy = $this->current(); if (! $policy || $policy->valid_until < today()->format('Y-m-d') || $policy->mode !== 'ceiling' || $policy->scope !== 'process_total' || $policy->currency !== 'MZN') { throw ValidationException::withMessages(['purchase_type' => 'É necessária regra de investimento própria, com duas aprovações administrativas e vigência válida. Sem regra aplicável, adjudicação e emissão de equipamento bloqueadas.']); } return $policy; } /** @return array|null */ public function check(Requisition $request, Quotation $quotation): ?array { if (! $request->items()->where('purchase_type', 'equipment')->exists()) { return null; } $policy = $this->requireCurrent(); $amount = $policy->includes_tax ? $quotation->total_minor : $quotation->subtotal_minor; if ($amount > $policy->ceiling_minor || $quotation->currency !== 'MZN') { throw ValidationException::withMessages(['investment' => 'O total do processo excede o tecto de investimento da empresa ou usa moeda não suportada. Não há dispensa automática.']); } app(EquipmentWorkflow::class)->assertTechnical($request); $entry = BudgetEntry::query()->where('requisition_id', $request->id)->where('kind', 'reserve')->first(); $budget = $entry ? Budget::query()->lockForUpdate()->find($entry->budget_id) : null; if (! $budget || ! $budget->approved_by || $budget->kind !== 'investment' || $budget->cost_center_id !== $request->cost_center_id || (int) $budget->year !== now()->year) { throw ValidationException::withMessages(['investment' => 'Exige reserva em dotação de investimento aprovada do centro e exercício. Não se atribui orçamento retroactivo.']); } $basis = app(EquipmentWorkflow::class)->basis($request); $technical = TechnicalReview::query()->where('requisition_id', $request->id)->where('basis_hash', $basis)->where('decision', 'approved')->firstOrFail(); return [...$policy->only(['id', 'revision', 'mode', 'scope', 'currency', 'ceiling_minor', 'includes_tax', 'valid_from', 'valid_until']), 'reviewer_ids' => InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->orderBy('reviewed_by')->pluck('reviewed_by')->all(), 'budget_id' => $budget->id, 'technical_review_id' => $technical->id, 'technical_basis_hash' => $basis]; } }