id() === $user->id && $context->userId() === $user->id && $context->unitId() !== null, 403); $s = $context->identifiers() + ['unit_id' => $context->unitId()]; $context->selectFor($user->id, $s['tenant_id'], $s['company_id'], $s['unit_id']); foreach (array_unique(['view', $right]) as $permission) { app(Permissions::class)->require($user->id, $s['tenant_id'], $s['company_id'], 'purchasing_budget.'.$permission, $s['unit_id']); } return $s; } private function input(array $data, array $rules): array { abort_if(array_diff(array_keys($data), array_keys($rules)) !== [], 422, 'Campo protegido ou fora do percurso sintético.'); return Validator::make($data, $rules)->validate(); } private function money(string $value): int { if (! preg_match('/^(0|[1-9][0-9]{0,11})(\.[0-9]{1,2})?$/D', $value)) { throw ValidationException::withMessages(['amount' => 'Use um montante MZN com ponto e até duas casas, sem arredondamento.']); } return BigDecimal::of($value)->multipliedBy(100)->toInt(); } public function listing(User $user): array { $s = $this->scope($user, 'view'); $engine = app(BudgetAdapter::class)->for($user); $budgets = DB::table('cb_budgets')->where($s)->orderBy('id')->get()->map(fn ($b) => (array) $b + ['balance' => $engine->balance($b->id)])->all(); $names = DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id')->where('m.tenant_id', $s['tenant_id'])->where('m.company_id', $s['company_id'])->pluck('u.name', 'u.id')->all(); return ['synthetic_only' => true, 'currency' => 'MZN', 'current_year' => $engine->context->year, 'budgets' => $budgets, 'centres' => DB::table('cb_centres')->where($s)->orderBy('id')->get(), 'origins' => DB::table('cb_origins')->where($s)->orderBy('id')->get(), 'entries' => DB::table('cb_budget_entries')->where($s)->orderBy('id')->get(), 'consumptions' => DB::table('cb_budget_consumptions')->where($s)->orderBy('id')->get(), 'consumption_sources' => DB::table('cb_consumption_sources')->where($s)->orderBy('id')->get(), 'reinforcements' => DB::table('cb_budget_reinforcements')->where($s)->orderBy('id')->get(['id', 'budget_id', 'created_by', 'reviewed_by', 'reference', 'amount_minor', 'reason', 'status', 'review_reason']), 'history' => DB::table('cb_audit')->where($s)->orderBy('id')->get(), 'actors' => $names]; } public function command(User $user, string $action, array $input, string $key): array { $right = match ($action) { 'create' => 'create', 'approve' => 'approve', 'movement' => 'record', 'consume' => 'consume', 'reinforcement' => 'reinforcement.propose', 'review' => 'reinforcement.review', default => abort(404), }; $s = $this->scope($user, $right); abort_unless(Str::isUuid($key), 422, 'Chave de comando em falta.'); ksort($input); $hash = hash('sha256', json_encode($input, JSON_THROW_ON_ERROR)); try { return DB::transaction(function () use ($user, $action, $right, $input, $key, $hash, $s) { DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail(); $this->scope($user, $right); $commands = DB::table('cb_ui_commands')->where($s)->where('actor_id', $user->id)->where('command_key', $key); if ($old = $commands->first()) { abort_unless($old->action === $action && hash_equals($old->request_hash, $hash), 409, 'Chave já usada com outro conteúdo.'); return json_decode($old->result, true, 32, JSON_THROW_ON_ERROR) + ['replayed' => true]; } $adapter = app(BudgetAdapter::class); $engine = $adapter->for($user); $idRule = 'required|integer|min:1'; if ($action === 'create') { $d = $this->input($input, ['cost_center_id' => $idRule, 'year' => 'required|integer|min:2026|max:2100', 'kind' => 'required|in:operating,investment', 'includes_tax' => 'required|boolean', 'amount' => 'required|string', 'reason' => 'required|string|min:3|max:4000']); $d['amount_minor'] = $this->money($d['amount']); unset($d['amount']); $d['includes_tax'] = (bool) $d['includes_tax']; $id = $adapter->create($user, $d); } elseif ($action === 'approve') { $d = $this->input($input, ['budget_id' => $idRule]); $id = $d['budget_id']; $engine->approve($id); } elseif ($action === 'reinforcement') { $d = $this->input($input, ['budget_id' => $idRule, 'amount' => 'required|string', 'reference' => 'required|string|max:100', 'reason' => 'required|string|min:3|max:4000']); $id = $engine->proposeReinforcement($d['budget_id'], $this->money($d['amount']), Str::upper(Str::squish($d['reference'])), trim($d['reason'])); } elseif ($action === 'review') { $d = $this->input($input, ['reinforcement_id' => $idRule, 'decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:3|max:4000']); $id = $d['reinforcement_id']; $engine->reviewReinforcement($id, $d['decision'], $d['reason']); } else { $rules = ['origin_id' => $idRule, 'total' => 'required|string', 'subtotal' => 'required|string']; $rules += $action === 'movement' ? ['kind' => 'required|in:reserve,award,commit,release'] : ['reference' => 'required|string|min:3|max:100']; $d = $this->input($input, $rules); $origin = DB::table('cb_origins')->where($s)->where('id', $d['origin_id'])->first() ?? abort(403, 'Origem fora deste contexto.'); $total = $this->money($d['total']); $net = $this->money($d['subtotal']); abort_if($net > $total, 422, 'Subtotal não pode exceder o total indicado.'); $entries = DB::table('cb_budget_entries')->where($s)->where('requisition_id', $origin->id); if ($action === 'movement') { $kind = $d['kind']; if (! (clone $entries)->where('kind', $kind)->exists()) { // Procurement sequence, not budget arithmetic. No reopening or invented reversal. $reserved = (clone $entries)->where('kind', 'reserve')->exists(); $closed = (clone $entries)->whereIn('kind', ['commit', 'release'])->exists(); abort_if($closed || ($kind !== 'reserve' && ! $reserved) || ($kind === 'commit' && ! (clone $entries)->where('kind', 'award')->exists()), 409, 'Sequência indisponível: reserve, ajuste a adjudicação e depois comprometa; libertação apenas antes do compromisso.'); } $effect = $adapter->record($user, $origin->id, $kind, $total, $net); abort_unless($effect, 409, 'Falta preparar e aprovar uma dotação para este centro/exercício.'); $id = $effect['budget_id']; } else { $reference = Str::upper(Str::squish($d['reference'])); $sources = DB::table('cb_consumption_sources')->where($s)->where('reference', $reference); if ($source = $sources->first()) { abort_unless((int) $source->origin_id === $origin->id && (int) $source->total_minor === $total && (int) $source->subtotal_minor === $net, 409, 'Referência sintética já usada com outros valores ou origem.'); $id = $source->id; } else { $id = DB::table('cb_consumption_sources')->insertGetId($s + ['origin_id' => $origin->id, 'reference' => $reference, 'total_minor' => $total, 'subtotal_minor' => $net, 'created_by' => $user->id, 'created_at' => now()]); } $engine->consume($origin->id, $id, $total, $net); } } $result = ['id' => (int) $id, 'action' => $action]; DB::table('cb_ui_commands')->insert($s + ['actor_id' => $user->id, 'command_key' => $key, 'action' => $action, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]); return $result + ['replayed' => false]; }); } catch (BudgetFailure $e) { abort_if($e->status !== 422, $e->status, $e->getMessage()); throw ValidationException::withMessages([$e->field => $e->getMessage()]); } } }