Implementa regras de investimento por empresa com dupla aprovacao

This commit is contained in:
2026-10-28 16:57:45 +02:00
parent 153ba2c1af
commit 95dd2cc1ed
37 changed files with 1418 additions and 28 deletions
+3 -2
View File
@@ -45,10 +45,11 @@ class BudgetController extends Controller
{
$this->access($request);
$data = $request->validate(['cost_center_id' => ['required', 'integer', Rule::exists('cost_centers', 'id')->where('tenant_id', $request->user()->tenant_id)], 'year' => 'required|integer|min:2026|max:2100', 'includes_tax' => 'required|boolean', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'reason' => 'required|string|max:4000']);
DB::transaction(function () use ($request, $data) {
$kind = $request->validate(['kind' => ['sometimes', Rule::in(['operating', 'investment'])]])['kind'] ?? 'operating';
DB::transaction(function () use ($request, $data, $kind) {
app(ConflictGuard::class)->lockCompany($request->user());
abort_if(Budget::query()->where('cost_center_id', $data['cost_center_id'])->where('year', $data['year'])->exists(), 409, 'Já existe dotação para este centro e exercício.');
$budget = Budget::query()->create(['tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'cost_center_id' => $data['cost_center_id'], 'year' => $data['year'], 'includes_tax' => $data['includes_tax'], 'amount_minor' => BigDecimal::of($data['amount'])->multipliedBy(100)->toInt(), 'reason' => $data['reason']]);
$budget = Budget::query()->create(['tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'cost_center_id' => $data['cost_center_id'], 'year' => $data['year'], 'kind' => $kind, 'includes_tax' => $data['includes_tax'], 'amount_minor' => BigDecimal::of($data['amount'])->multipliedBy(100)->toInt(), 'reason' => $data['reason']]);
app(RequisitionWorkflow::class)->audit($request->user(), 'budget.created', null, null, $budget->toArray());
});
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\InvestmentPolicy;
use App\Models\InvestmentPolicyReview;
use App\Services\InvestmentWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class InvestmentPolicyController extends Controller
{
public function index(Request $request, InvestmentWorkflow $workflow): Response
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
$current = $workflow->current();
return Inertia::render('investment/index', ['policies' => InvestmentPolicy::query()->latest('revision')->get(), 'reviews' => InvestmentPolicyReview::query()->orderBy('id')->get(), 'currentPolicyId' => $current && $current->valid_until >= today()->format('Y-m-d') ? $current->id : null, 'canManage' => $request->user()->purchasing_role === PurchasingRole::Admin]);
}
public function store(Request $request, InvestmentWorkflow $workflow): RedirectResponse
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'mode' => 'required|in:ceiling', 'scope' => 'required|in:process_total', 'currency' => 'required|in:MZN', 'ceiling' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'includes_tax' => 'required|boolean', 'valid_from' => 'required|date_format:Y-m-d', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:valid_from', 'reason' => 'required|string|max:4000']);
$workflow->propose($request->user(), $data);
return back();
}
public function review(Request $request, InvestmentPolicy $policy, InvestmentWorkflow $workflow): RedirectResponse
{
$data = $request->validate(['decision' => 'required|in:approved,rejected', 'reason' => 'required|string|max:4000']);
$workflow->review($request->user(), $policy, $data['decision'], $data['reason']);
return back();
}
}
+1
View File
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Model;
* @property int $amount_minor
* @property int $created_by
* @property int|null $approved_by
* @property string $kind
*/
class Budget extends Model
{
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property int $id
* @property int $tenant_id
* @property int $created_by
* @property int $revision
* @property string $status
*/
class InvestmentPolicy extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $attributes = ['status' => 'pending'];
protected function casts(): array
{
return ['includes_tax' => 'boolean', 'ceiling_minor' => 'integer'];
}
protected static function booted(): void
{
static::updating(function (InvestmentPolicy $policy) {
if ($policy->getRawOriginal('status') !== 'pending' || array_diff(array_keys($policy->getDirty()), ['status', 'decided_at', 'updated_at']) !== []) {
throw new \LogicException('Preservar os parâmetros e as decisões da regra.');
}
});
static::deleting(fn () => throw new \LogicException('Preservar versões de investimento.'));
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class InvestmentPolicyReview extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
}
+2 -1
View File
@@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property string $comparison_hash
* @property array<string, mixed> $approval_snapshot
* @property list<array<string, mixed>> $decision_matrix
* @property array<string,mixed>|null $investment_snapshot
*/
class PurchaseAward extends Model
{
@@ -25,7 +26,7 @@ class PurchaseAward extends Model
protected function casts(): array
{
return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'delivery_date' => 'date:Y-m-d'];
return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'investment_snapshot' => 'array', 'delivery_date' => 'date:Y-m-d'];
}
/** @return HasMany<PurchaseAwardApproval, $this> */
+4
View File
@@ -30,6 +30,10 @@ class BudgetWorkflow
if (! $budget) {
return null;
}
$requiredKind = $request->items()->where('purchase_type', 'equipment')->exists() ? 'investment' : 'operating';
if ($budget->kind !== $requiredKind) {
throw ValidationException::withMessages(['budget' => 'A dotação deve ter a classificação explícita adequada: investimento para processos com equipamento; corrente para os restantes.']);
}
if (! $budget->approved_by || ($kind !== 'release' && (int) $budget->year !== now()->year)) {
throw ValidationException::withMessages(['budget' => 'Dotação pendente de aprovação ou exercício encerrado.']);
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\BudgetEntry;
use App\Models\InvestmentPolicy;
use App\Models\InvestmentPolicyReview;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\TechnicalReview;
use App\Models\User;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class InvestmentWorkflow
{
private function admin(User $user): void
{
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Admin, 403);
}
/** @param array<string,mixed> $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->purchasing_role !== 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<string,mixed>|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];
}
}
+14 -7
View File
@@ -36,6 +36,7 @@ class PurchaseWorkflow
$this->sourceApproved($requisition, $rules);
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']);
$result = $this->checkComparison($user, $requisition, $quote);
$investment = app(InvestmentWorkflow::class)->check($requisition, $quote);
app(BudgetWorkflow::class)->record($user, $requisition, 'award', $quote->total_minor, $quote->subtotal_minor);
$award = PurchaseAward::query()->create([
'tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'quotation_id' => $quote->id,
@@ -43,8 +44,9 @@ class PurchaseWorkflow
'delivery_location' => $data['delivery_location'], 'status' => 'pending',
'subtotal_minor' => $quote->subtotal_minor, 'tax_minor' => $quote->tax_minor, 'total_minor' => $quote->total_minor,
'approval_snapshot' => $rules, 'decision_matrix' => $result['rows'], 'comparison_hash' => $result['fingerprint'],
'investment_snapshot' => $investment,
]);
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $quote->total_minor : $quote->subtotal_minor);
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $quote->total_minor : $quote->subtotal_minor, $requisition->items()->where('purchase_type', 'equipment')->exists());
foreach ($roles as $position => $role) {
$award->approvals()->create(['tenant_id' => $user->tenant_id, 'position' => $position + 1, 'role' => $role]);
}
@@ -130,6 +132,8 @@ class PurchaseWorkflow
$snapshot = [
'development_only' => true, 'budget_checked' => false,
'budget_control' => app(BudgetWorkflow::class)->record($user, $requisition, 'commit', $quote->total_minor, $quote->subtotal_minor),
'investment_rules' => $award->investment_snapshot,
'equipment_requests' => $requisition->items()->where('purchase_type', 'equipment')->get()->map(fn ($line) => $line->only(['id', 'description', 'production_area']))->all(),
'company' => Tenant::query()->findOrFail($user->tenant_id)->only(['id', 'name']),
'requisition' => $requisition->only(['id', 'number', 'justification']),
'department' => $requisition->department->only(['id', 'name']),
@@ -166,6 +170,9 @@ class PurchaseWorkflow
$this->supportedItems($requisition);
$this->sourceApproved($requisition, $rules);
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($award->quotation_id);
if (app(InvestmentWorkflow::class)->check($requisition, $quote) !== $award->investment_snapshot) {
throw ValidationException::withMessages(['investment' => 'A regra de investimento ou a base técnica/orçamental mudou. As aprovações anteriores não autorizam uma emissão com outra base.']);
}
$result = $this->checkComparison($user, $requisition, $quote);
if ($result['fingerprint'] !== $award->comparison_hash || $award->approval_snapshot !== $rules ||
$quote->subtotal_minor !== $award->subtotal_minor || $quote->tax_minor !== $award->tax_minor || $quote->total_minor !== $award->total_minor) {
@@ -214,13 +221,13 @@ class PurchaseWorkflow
/** @param array{first_limit_minor: int, second_limit_minor: int, approval_includes_tax: bool, rules_provisional: bool} $rules
* @return list<string>
*/
private function roles(array $rules, int $amount): array
private function roles(array $rules, int $amount, bool $equipment = false): array
{
$roles = ['department_head'];
if ($amount > $rules['first_limit_minor']) {
$roles[] = 'procurement';
}
if ($amount > $rules['second_limit_minor']) {
if ($amount > $rules['second_limit_minor'] || $equipment) {
$roles[] = 'finance';
}
@@ -230,7 +237,7 @@ class PurchaseWorkflow
/** @param array{first_limit_minor: int, second_limit_minor: int, approval_includes_tax: bool, rules_provisional: bool} $rules */
private function sourceApproved(Requisition $requisition, array $rules): void
{
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $requisition->total_minor : $requisition->subtotal_minor);
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $requisition->total_minor : $requisition->subtotal_minor, $requisition->items()->where('purchase_type', 'equipment')->exists());
$steps = $requisition->steps()->get();
$seen = [$requisition->requester_id];
if ($requisition->status !== 'approved' || $steps->count() !== count($roles)) {
@@ -248,7 +255,7 @@ class PurchaseWorkflow
private function validateFinalApprovals(PurchaseAward $award, Requisition $requisition): void
{
$rules = $this->rules($requisition);
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $award->total_minor : $award->subtotal_minor);
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $award->total_minor : $award->subtotal_minor, $requisition->items()->where('purchase_type', 'equipment')->exists());
$steps = $award->approvals()->get();
$seen = [$requisition->requester_id, $award->created_by];
if ($steps->count() !== count($roles)) {
@@ -268,10 +275,10 @@ class PurchaseWorkflow
private function supportedItems(Requisition $requisition): void
{
if ($requisition->items()->where('purchase_type', 'equipment')->exists()) {
throw ValidationException::withMessages(['purchase_type' => 'O limite próprio de investimento e a sua regra de aplicação ainda não foram definidos pela empresa. A validação técnica e Finanças não dispensam essa decisão; adjudicação e emissão de equipamento bloqueadas.']);
app(InvestmentWorkflow::class)->requireCurrent();
}
foreach ($requisition->items()->get() as $line) {
if (! in_array($line->purchase_type, ['consumable', 'service', 'other', 'spare_part']) || ($line->purchase_type === 'spare_part' && (! $line->equipment_id || ! $line->spare_part_id))) {
if (! in_array($line->purchase_type, ['consumable', 'service', 'other', 'spare_part', 'equipment']) || ($line->purchase_type === 'spare_part' && (! $line->equipment_id || ! $line->spare_part_id))) {
throw ValidationException::withMessages(['purchase_type' => 'Tipo não suportado ou peça sem equipamento/artigo associado. Complete o cadastro antes da compra.']);
}
app(EquipmentWorkflow::class)->line($line->toArray(), $requisition->department_id, $requisition->priority);
+1 -1
View File
@@ -134,7 +134,7 @@ class ReceiptWorkflow
throw ValidationException::withMessages(['items' => 'Descreva as reservas, danos ou divergências de qualidade.']);
}
$stockItem = ! empty($input['stock_item_id']) ? StockItem::query()->lockForUpdate()->findOrFail($input['stock_item_id']) : null;
$physical = in_array($source->purchase_type, ['consumable', 'other', 'spare_part']);
$physical = in_array($source->purchase_type, ['consumable', 'other', 'spare_part', 'equipment']);
if ($source->purchase_type === 'spare_part') {
if (! $source->spare_part_id || ! $source->equipment_id) {
throw ValidationException::withMessages(['items' => 'Peça sem associação autorizada na ordem. A recepção está bloqueada.']);