Files
compras/app/Services/PeopleClarifications.php
T
nala c6373365bf
tests / ci (push) Canceled after 0s
commit9
2026-10-30 20:02:30 +02:00

192 lines
14 KiB
PHP

<?php
namespace App\Services;
use App\Models\PeopleProcess;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use stdClass;
class PeopleClarifications
{
public function canRaise(User $actor, PeopleProcess $process): bool
{
$access = app(PeopleAccess::class);
$workflow = app(PeopleHiring::class);
return ! app(OrganizationAccess::class)->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<string,mixed> */
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<array<string,mixed>> */
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<string,mixed> */
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<array<string,mixed>> */
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<string,mixed> $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']]);
});
}
}