maintenanceOnly($actor) && $access->can($actor, $process, 'view') && $access->can($actor, $process, 'interviews') && (($access->can($actor, $process, 'prepare') && $workflow->allowed($actor, $process->kind, 'prepare')) || ($access->can($actor, $process, 'review') && $workflow->allowed($actor, $process->kind, 'review'))); } public function canRespond(User $actor, PeopleProcess $process, stdClass $row): bool { return $row->status === 'open' && $row->responsible_id === $actor->id && app(PeopleAccess::class)->can($actor, $process, 'prepare') && app(PeopleHiring::class)->allowed($actor, $process->kind, 'prepare') && ! app(OrganizationAccess::class)->maintenanceOnly($actor); } public function canReview(User $actor, PeopleProcess $process, stdClass $row): bool { $responses = json_decode($row->responses, true, 32, JSON_THROW_ON_ERROR); $registeredBy = DB::table('people_applications')->where('id', $row->people_application_id)->value('created_by'); return $row->status !== 'resolved' && ! in_array($actor->id, [$process->created_by, $registeredBy, $row->responsible_id, ...array_column($responses, 'by')], true) && app(PeopleAccess::class)->can($actor, $process, 'review') && app(PeopleAccess::class)->can($actor, $process, 'interviews') && app(PeopleHiring::class)->allowed($actor, $process->kind, 'review') && ! app(OrganizationAccess::class)->maintenanceOnly($actor); } public function canRead(User $actor, PeopleProcess $process, stdClass $row): bool { return app(PeopleAccess::class)->can($actor, $process, 'view') && (app(PeopleAccess::class)->can($actor, $process, 'interviews') || ($row->responsible_id === $actor->id && app(PeopleAccess::class)->can($actor, $process, 'prepare') && app(PeopleHiring::class)->allowed($actor, $process->kind, 'prepare'))); } /** @return array */ public function snapshot(stdClass $row): array { return [...(array) $row, 'responses' => json_decode($row->responses, true, 32, JSON_THROW_ON_ERROR), 'decisions' => json_decode($row->decisions, true, 32, JSON_THROW_ON_ERROR)]; } /** @return list> */ public function metadata(PeopleProcess $process, int $application): array { if (! Schema::hasTable('people_clarifications')) { return []; } return array_values(DB::table('people_clarifications')->where('tenant_id', $process->tenant_id)->where('people_process_id', $process->id)->where('people_application_id', $application)->orderBy('id')->get(['id', 'people_review_id', 'criterion_index', 'status', 'version', 'due_on', 'decisions', 'responses', 'raised_by', 'responsible_id'])->map(function ($row) { $decisions = json_decode($row->decisions, true, 32, JSON_THROW_ON_ERROR); $responses = json_decode($row->responses, true, 32, JSON_THROW_ON_ERROR); $outcome = $decisions === [] ? null : $decisions[array_key_last($decisions)]['outcome']; $data = (array) $row; unset($data['decisions'], $data['responses'], $data['raised_by'], $data['responsible_id'], $data['version'], $data['due_on']); return [...$data, 'outcome' => $outcome, 'evidence_hash' => hash('sha256', json_encode([$responses, $decisions], JSON_THROW_ON_ERROR)), 'contributors' => array_values(array_unique([$row->raised_by, $row->responsible_id, ...array_column($responses, 'by'), ...array_column($decisions, 'by')]))]; })->all()); } /** @return array */ public function page(User $actor, PeopleProcess $process): array { if (! Schema::hasTable('people_clarifications')) { return ['ready' => false, 'items' => [], 'sources' => [], 'users' => []]; } $rows = DB::table('people_clarifications')->where('tenant_id', $process->tenant_id)->where('people_process_id', $process->id)->orderBy('id')->get(); $items = []; foreach ($rows as $row) { $read = $this->canRead($actor, $process, $row); $decisions = json_decode($row->decisions, true, 32, JSON_THROW_ON_ERROR); $items[] = ['id' => $row->id, 'application_id' => $row->people_application_id, 'status' => $row->status, 'version' => $row->version, 'due_on' => $row->due_on, 'outcome' => $decisions === [] ? null : $decisions[array_key_last($decisions)]['outcome'], 'canRespond' => $this->canRespond($actor, $process, $row), 'canReview' => $this->canReview($actor, $process, $row), 'canReassign' => $row->status !== 'resolved' && $this->canRaise($actor, $process), ...($read ? ['question' => $row->question, 'responses' => json_decode($row->responses, true, 32, JSON_THROW_ON_ERROR), 'decisions' => json_decode($row->decisions, true, 32, JSON_THROW_ON_ERROR), 'responsible_id' => $row->responsible_id] : [])]; } $sources = $this->sources($actor, $process); $users = []; if ($this->canRaise($actor, $process)) { $users = User::query()->where('tenant_id', $process->tenant_id)->orderBy('name')->get()->filter(fn ($user) => $this->eligibleResponsible($user, $process))->map(fn ($user) => $user->only(['id', 'name']))->values()->all(); } return ['ready' => true, 'items' => $items, 'sources' => $sources, 'users' => $users, 'preservedDecision' => in_array($process->status, ['selected', 'contract_draft'], true)]; } /** @return list> */ public function sources(User $actor, PeopleProcess $process): array { if (! Schema::hasTable('people_clarifications') || ! in_array($process->status, ['assessment', 'selection_review', 'selected', 'contract_draft'], true) || ! $this->canRaise($actor, $process)) { return []; } $existing = DB::table('people_clarifications')->where('tenant_id', $process->tenant_id)->where('people_process_id', $process->id)->get(['people_review_id', 'criterion_index']); $sources = []; foreach (DB::table('people_reviews as r')->join('people_applications as a', 'a.id', '=', 'r.people_application_id')->where('r.tenant_id', $process->tenant_id)->where('a.people_process_id', $process->id)->orderBy('r.id')->get(['r.id', 'r.answers', 'a.id as application_id']) as $review) { foreach (json_decode($review->answers, true, 32, JSON_THROW_ON_ERROR) as $index => $answer) { if (trim($answer['clarification'] ?? '') !== '' && ! $existing->contains(fn ($row) => $row->people_review_id === $review->id && $row->criterion_index === $index)) { $sources[] = ['review_id' => $review->id, 'criterion_index' => $index, 'application_id' => $review->application_id, 'question' => $answer['clarification']]; } } } return $sources; } private function eligibleResponsible(User $actor, PeopleProcess $process): bool { return app(PeopleAccess::class)->can($actor, $process, 'view') && app(PeopleAccess::class)->can($actor, $process, 'prepare') && app(PeopleHiring::class)->allowed($actor, $process->kind, 'prepare') && ! app(OrganizationAccess::class)->maintenanceOnly($actor); } /** @param array $input */ public function execute(User $actor, int $processId, ?int $id, string $command, array $input): void { DB::transaction(function () use ($actor, $processId, $id, $command, $input) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor); abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403); $process = PeopleProcess::query()->lockForUpdate()->findOrFail($processId); abort_unless(app(PeopleAccess::class)->can($actor, $process, 'view'), 403); $data = Validator::make($input, ['reason' => 'required|string|min:10|max:4000'])->validate(); if ($command === 'create' || $command === 'reassign') { abort_unless($this->canRaise($actor, $process), 403); $data += Validator::make($input, ['responsible_id' => 'required|integer|min:1', 'due_on' => 'required|date_format:Y-m-d'])->validate(); $responsible = User::query()->where('tenant_id', $actor->tenant_id)->where('id', $data['responsible_id'])->firstOrFail(); abort_unless($this->eligibleResponsible($responsible, $process), 422, 'Escolha uma pessoa com preparação atribuída neste processo.'); $data['responsible_id'] = $responsible->id; } if ($command === 'create') { abort_unless(in_array($process->status, ['assessment', 'selection_review', 'selected', 'contract_draft'], true), 409); $data += Validator::make($input, ['operation_key' => 'required|uuid', 'review_id' => 'required|integer|min:1', 'criterion_index' => 'required|integer|between:0,19'])->validate(); $review = DB::table('people_reviews as r')->join('people_applications as a', 'a.id', '=', 'r.people_application_id')->where('r.tenant_id', $process->tenant_id)->where('a.people_process_id', $process->id)->where('r.id', $data['review_id'])->first(['r.id', 'r.answers', 'a.id as application_id']); abort_unless($review !== null, 404); $answers = json_decode($review->answers, true, 32, JSON_THROW_ON_ERROR); $question = trim($answers[$data['criterion_index']]['clarification'] ?? ''); abort_unless($question !== '', 422, 'Não existe esclarecimento nesse critério da avaliação.'); $hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); $existing = DB::table('people_clarifications')->where('tenant_id', $actor->tenant_id)->where('operation_key', $data['operation_key'])->first(); if ($existing !== null) { abort_unless($existing->raised_by === $actor->id && $existing->creation_hash === $hash, 409); return; } abort_if(DB::table('people_clarifications')->where('people_review_id', $review->id)->where('criterion_index', $data['criterion_index'])->exists(), 409, 'Este esclarecimento já tem seguimento.'); $id = DB::table('people_clarifications')->insertGetId(['tenant_id' => $process->tenant_id, 'people_process_id' => $process->id, 'people_application_id' => $review->application_id, 'people_review_id' => $review->id, 'criterion_index' => $data['criterion_index'], 'question' => $question, 'raised_by' => $actor->id, 'responsible_id' => $data['responsible_id'], 'due_on' => $data['due_on'], 'operation_key' => $data['operation_key'], 'creation_hash' => $hash, 'status' => 'open', 'version' => 1, 'responses' => '[]', 'decisions' => '[]', 'created_at' => now(), 'updated_at' => now()]); $before = null; } else { $row = DB::table('people_clarifications')->where('tenant_id', $process->tenant_id)->where('people_process_id', $process->id)->where('id', $id)->lockForUpdate()->first(); abort_unless($row !== null, 404); $version = Validator::make($input, ['version' => 'required|integer|min:1'])->validate(); if ($row->version !== (int) $version['version']) { throw ValidationException::withMessages(['version' => 'O esclarecimento mudou. Actualize antes de continuar.']); } abort_if($row->status === 'resolved', 409, 'A conclusão está preservada.'); $before = $this->snapshot($row); if ($command === 'reassign') { $values = ['responsible_id' => $data['responsible_id'], 'due_on' => $data['due_on'], 'status' => $data['responsible_id'] === $row->responsible_id ? $row->status : 'open']; } elseif ($command === 'respond') { abort_unless($this->canRespond($actor, $process, $row), 403); $reply = Validator::make($input, ['answer' => 'required|string|min:10|max:5000', 'evidence_type' => 'required|in:reported,observed,verified', 'reference' => 'nullable|required_if:evidence_type,verified|string|max:2000'])->validate(); $values = ['status' => 'responded', 'responses' => json_encode([...$before['responses'], [...$reply, 'by' => $actor->id, 'at' => now()->toIso8601String()]], JSON_THROW_ON_ERROR)]; } elseif ($command === 'review') { abort_unless($this->canReview($actor, $process, $row), 403); $decision = Validator::make($input, ['outcome' => 'required|in:accepted,insufficient,not_required,returned', 'no_conflict' => 'accepted'])->validate(); abort_unless($row->status === 'responded' || $decision['outcome'] === 'not_required', 409, 'Registe primeiro a resposta ou fundamente a dispensa de seguimento.'); $values = ['status' => $decision['outcome'] === 'returned' ? 'open' : 'resolved', 'decisions' => json_encode([...$before['decisions'], ['outcome' => $decision['outcome'], 'reason' => $data['reason'], 'by' => $actor->id, 'at' => now()->toIso8601String(), 'no_conflict' => true]], JSON_THROW_ON_ERROR)]; } else { abort(404); } DB::table('people_clarifications')->where('id', $row->id)->update([...$values, 'version' => $row->version + 1, 'updated_at' => now()]); } $after = DB::table('people_clarifications')->where('id', $id)->first(); app(RequisitionWorkflow::class)->audit($actor, 'people.clarification_'.$command, null, $before, ['id' => $process->id, 'clarification' => $this->snapshot($after), 'reason' => $data['reason']]); }); } }