scope = $ctx->identifiers() + ['unit_id' => $ctx->unitId()]; } public function actorId(): int { return $this->actor->id; } public function scope(): array { return $this->scope; } public function query(string $table): Builder { return DB::table('ir_'.$table)->where($this->scope); } public function atomic(callable $work): mixed { BudgetAdapter::guard(); abort_unless(config('purchasing-internal.candidate_enabled') === true, 404); return DB::transaction(function () use ($work) { $s = $this->scope; DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail(); $this->actor = User::query()->lockForUpdate()->findOrFail($this->actor->id); $ctx = app(CompanyContext::class); abort_unless(auth()->id() === $this->actor->id && $ctx->userId() === $this->actor->id && $s['unit_id'] !== null && $ctx->identifiers() + ['unit_id' => $ctx->unitId()] === $s, 409, 'Contexto alterado.'); $ctx->selectFor($this->actor->id, $s['tenant_id'], $s['company_id'], $s['unit_id']); abort_unless(request()->header('X-Purchasing-Context') === 'procurement.internal', 403, 'Escolha Compras internas.'); if (request()->hasSession() && request()->is('api/*')) { abort_unless(hash_equals(request()->session()->get('company_context.token', ''), (string) request()->header('X-SGF-Context')), 409, 'O contexto mudou.'); } return $work(); }); } public function has(string $right): bool { $s = $this->scope; return app(Permissions::class)->has($this->actor->id, $s['tenant_id'], $s['company_id'], 'purchasing_requisitions.'.$right, $s['unit_id']); } public function setup(): array { $row = $this->query('policies')->first(); abort_unless($row, 422, 'Configure a política e os cadastros autorizados.'); return json_decode($row->payload, true, 64, JSON_THROW_ON_ERROR) + ['revision' => (int) $row->version]; } private function department(): ?string { foreach ($this->setup()['members'] as $member) { if ($member['user_id'] === $this->actor->id) { return $member['department_ref']; } } return null; } public function allowedCentres(): array { $out = []; foreach ($this->setup()['centres'] as $c) { if ($this->has('configure') || $c['department_ref'] === $this->department()) { $name = DB::table('cb_centres')->where($this->scope)->where('id', $c['id'])->value('code'); $out[] = $c + ['label' => $name ?? 'Centro configurado']; } } return $out; } public function configurationSources(): array { if (! $this->has('configure')) { return []; } $s = $this->scope; return ['centres' => DB::table('cb_centres')->where($s)->get(['id', 'code'])->all(), 'members' => 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']) ->whereExists(function ($q) use ($s) { $q->selectRaw('1')->from('permission_grants as g')->whereColumn('g.user_id', 'u.id')->where('g.tenant_id', $s['tenant_id'])->where('g.company_id', $s['company_id'])->where('g.unit_id', $s['unit_id'])->where('g.permission', 'unit.view'); })->get(['u.id', 'u.name'])->all()]; } public function authorize(string $action, ?array $record): void { abort_unless($this->has('view'), 403, 'Consulta não autorizada.'); if ($action !== 'configure') { abort_unless($this->setup()['enabled'], 403, 'Opção interna não configurada para o produto.'); } if ($record !== null) { abort_unless($record['work_context'] === 'procurement.internal', 403); $all = $this->has('configure') || $this->has('approve.procurement') || $this->has('approve.finance'); // Scoped view is sufficient for reading; it never grants mutation rights. // load()/query() and atomic() retain tenant, company, unit and option checks. $visible = $action === 'view' || $all || $record['requester_id'] === $this->actor->id || ($this->has('approve.department_head') && $record['department_ref'] === $this->department()); abort_unless($visible, 404); } if (in_array($action, ['save', 'submit'], true)) { abort_unless($this->has('prepare') && $this->department() !== null, 403); if ($record !== null) { abort_unless($record['requester_id'] === $this->actor->id, 403); } } elseif ($action === 'configure') { abort_unless($this->has('configure'), 403); } elseif ($action === 'decide') { abort_unless($this->has('approve.department_head') || $this->has('approve.procurement') || $this->has('approve.finance'), 403); } elseif (str_starts_with($action, 'step:')) { $role = substr($action, 5); abort_unless($this->has('approve.'.$role) && ($role !== 'department_head' || $record['department_ref'] === $this->department()), 403); } } public function load(int $id): array { $row = $this->query('requests')->where('id', $id)->lockForUpdate()->first(); abort_unless($row, 404); $r = (array) $row; foreach (['data', 'approval_snapshot', 'reservation'] as $field) { $r[$field] = $r[$field] === null ? null : json_decode($r[$field], true, 64, JSON_THROW_ON_ERROR); } foreach (['id', 'requester_id', 'version', 'subtotal_minor', 'tax_minor', 'total_minor', 'cost_center_id'] as $field) { $r[$field] = (int) $r[$field]; } $r['steps'] = $this->query('steps')->where('request_id', $id)->orderBy('position')->get()->map(fn ($s) => (array) $s)->all(); $r['has_equipment'] = count(array_filter($r['data']['items'], fn ($i) => $i['purchase_type'] === 'equipment')) > 0; return $r; } public function draft(array $input, ?int $id): array { $data = Validator::make($input, [ 'cost_center_id' => 'required|integer|min:1', 'priority' => 'required|in:normal,high,urgent', 'needed_by' => 'required|date_format:Y-m-d|after_or_equal:today', 'justification' => 'required|string|min:10|max:5000', 'items' => 'required|array|min:1|max:50', 'items.*' => 'array:description,category,purchase_type,unit,quantity,unit_price,tax_rate', 'items.*.description' => 'required|string|max:255', 'items.*.category' => 'required|string|max:100', 'items.*.purchase_type' => 'required|in:consumable,service,other', 'items.*.unit' => 'required|string|max:30', 'items.*.quantity' => ['required', 'regex:/^\d{1,7}(\.\d{1,3})?$/D', 'numeric', 'min:0.001', 'max:1000000'], 'items.*.unit_price' => ['required', 'regex:/^\d{1,9}(\.\d{1,2})?$/D', 'numeric', 'min:0.01', 'max:100000000'], 'items.*.tax_rate' => ['required', 'regex:/^\d{1,3}(\.\d{1,2})?$/D', 'numeric', 'between:0,100'], ])->validate(); abort_if(array_diff(array_keys($input), array_keys($data)) !== [], 422, 'Campo protegido.'); $department = $this->department(); $mapped = false; foreach ($this->setup()['centres'] as $centre) { if ($centre['id'] === (int) $data['cost_center_id'] && $centre['department_ref'] === $department) { $mapped = true; } } abort_unless($mapped && DB::table('cb_centres')->where($this->scope)->where('id', $data['cost_center_id'])->exists(), 403, 'Centro fora do cadastro autorizado.'); $subtotal = 0; $tax = 0; foreach ($data['items'] as &$line) { $line = [...$line, ...InternalRequisitions::line($line['quantity'], $line['unit_price'], $line['tax_rate'])]; $subtotal += $line['subtotal_minor']; $tax += $line['tax_minor']; } unset($line); abort_if($subtotal + $tax > 100000000000000, 422, 'Total excede o limite suportado.'); $hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); if ($id === null) { $old = $this->query('requests')->where('origin_key', $this->originKey)->first(); if ($old) { abort_unless((int) $old->requester_id === $this->actor->id && hash_equals($old->creation_hash, $hash), 409, 'Origem já registada com outros dados.'); return $this->load($old->id); } } $values = ['cost_center_id' => $data['cost_center_id'], 'department_ref' => $department, 'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax, 'data' => json_encode($data, JSON_THROW_ON_ERROR), 'updated_at' => now()]; if ($id === null) { $id = DB::table('ir_requests')->insertGetId($this->scope + $values + ['requester_id' => $this->actor->id, 'number' => 'IR-'.now()->year.'-'.$this->originKey, 'work_context' => 'procurement.internal', 'origin_key' => $this->originKey, 'creation_hash' => $hash, 'created_at' => now()]); } else { $old = $this->load($id); $this->query('requests')->where('id', $id)->update($values + ['version' => $old['version'] + 1]); } return $this->event($id, 'draft.saved'); } public function policy(array $record): array { $policy = $this->setup(); abort_unless($this->department() === $record['department_ref'], 422, 'A atribuição ao departamento mudou; reveja o rascunho.'); abort_unless($record['data']['needed_by'] >= today()->toDateString(), 422, 'Actualize a data pretendida.'); $mapped = false; foreach ($policy['centres'] as $c) { if ($c['id'] === $record['cost_center_id'] && $c['department_ref'] === $record['department_ref']) { $mapped = true; } } abort_unless($mapped, 422, 'A associação do centro mudou; reveja o rascunho.'); return array_intersect_key($policy, array_flip(['revision', 'first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional', 'budget_required'])); } public function reserve(array $record): ?array { $origin = DB::table('cb_origins')->where($this->scope)->where('reference', 'internal-requisition:'.$record['origin_key'])->first(); $id = $origin?->id ?? DB::table('cb_origins')->insertGetId($this->scope + ['cost_center_id' => $record['cost_center_id'], 'required_kind' => 'operating', 'reference' => 'internal-requisition:'.$record['origin_key']]); $result = app(BudgetAdapter::class)->record($this->actor, (int) $id, 'reserve', $record['total_minor'], $record['subtotal_minor']); abort_if($result === null && $this->setup()['budget_required'], 422, 'Dotação não configurada; nenhuma submissão ou reserva foi gravada.'); // Persisted atomically with submission; no intermediate root mutation/version. $this->pendingReservation = ['origin_id' => (int) $id, 'result' => $result]; return $result; } private array $pendingReservation = []; public function submitted(array $record, array $policy, array $roles): array { foreach ($roles as $i => $role) { DB::table('ir_steps')->insert($this->scope + ['request_id' => $record['id'], 'position' => $i + 1, 'role' => $role]); } $this->query('requests')->where('id', $record['id'])->update(['status' => 'pending', 'version' => $record['version'] + 1, 'approval_snapshot' => json_encode($policy, JSON_THROW_ON_ERROR), 'budget_origin_id' => $this->pendingReservation['origin_id'], 'reservation' => json_encode($this->pendingReservation['result'], JSON_THROW_ON_ERROR), 'updated_at' => now()]); return $this->event($record['id'], 'submitted'); } public function decided(array $record, array $step, string $decision, ?string $comment, string $status): array { $this->query('steps')->where('id', $step['id'])->update(['decision' => $decision, 'approver_id' => $this->actor->id, 'comment' => $comment, 'decided_at' => now()]); $this->query('requests')->where('id', $record['id'])->update(['status' => $status, 'version' => $record['version'] + 1, 'updated_at' => now()]); return $this->event($record['id'], 'approval.'.$decision); } public function release(array $record): void { app(BudgetAdapter::class)->record($this->actor, (int) $record['budget_origin_id'], 'release'); } private function event(int $id, string $action): array { $record = $this->load($id); DB::table('ir_events')->insert($this->scope + ['request_id' => $id, 'version' => $record['version'], 'actor_id' => $this->actor->id, 'action' => $action, 'payload' => json_encode($record, JSON_THROW_ON_ERROR), 'created_at' => now()]); return $record; } public function replay(string $key, string $hash): ?array { $old = $this->query('commands')->where(['actor_id' => $this->actor->id, 'operation_key' => $key])->first(); if (! $old) { return null; } abort_unless(hash_equals($old->request_hash, $hash), 409, 'Chave usada com outro conteúdo.'); return json_decode($old->result, true, 64, JSON_THROW_ON_ERROR); } public function remember(string $key, string $hash, array $result): void { DB::table('ir_commands')->insert($this->scope + ['actor_id' => $this->actor->id, 'operation_key' => $key, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]); } }