Files
nala c6373365bf
tests / ci (push) Canceled after 0s
commit9
2026-10-30 20:02:30 +02:00

214 lines
17 KiB
PHP

<?php
namespace App\Services;
use App\Models\PeopleProcess;
use App\Models\User;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class PeopleHiring
{
public const PERMISSIONS = ['people_service_prepare', 'people_service_review', 'people_staff_prepare', 'people_staff_review'];
public function allowed(User $actor, string $kind, ?string $action = null): bool
{
if (! in_array($kind, ['service', 'staff'], true)) {
return false;
}
return $actor->hasFunctions(['admin', ...($action === null ? ['people_'.$kind.'_prepare', 'people_'.$kind.'_review'] : ['people_'.$kind.'_'.$action])]);
}
/** @return list<string> */
public function kinds(User $actor): array
{
return array_values(array_filter(['service', 'staff'], fn ($kind) => $this->allowed($actor, $kind)));
}
/** @return array<string,mixed> */
public function snapshot(PeopleProcess $record): array
{
return $record->attributesToArray();
}
/** @return list<int> */
public function decisionContributors(PeopleProcess $record): array
{
$people = [$record->created_by, $record->selection['proposed_by'] ?? $record->created_by];
foreach ($record->selection['rows'] ?? [] as $row) {
$people = [...$people, $row['registered_by'], ...array_column($row['reviews'], 'reviewed_by')];
foreach ($row['clarifications'] ?? [] as $clarification) {
$people = [...$people, ...$clarification['contributors']];
}
}
return array_values(array_unique($people));
}
/** @return list<array<string,mixed>> */
public function matrix(PeopleProcess $record): array
{
$rows = [];
$electronic = Schema::hasTable('candidate_triage') ? DB::table('candidate_triage as t')->join('candidate_submissions as s', 's.id', '=', 't.candidate_submission_id')
->where('t.tenant_id', $record->tenant_id)->where('t.people_process_id', $record->id)->whereNotNull('t.people_application_id')
->get(['t.people_application_id', 't.profile_version', 's.id', 's.reference'])->keyBy('people_application_id') : collect();
foreach (DB::table('people_applications')->where('tenant_id', $record->tenant_id)->where('people_process_id', $record->id)->orderBy('id')->get() as $application) {
$reviews = DB::table('people_reviews')->where('tenant_id', $record->tenant_id)->where('people_application_id', $application->id)->orderBy('id')->get();
// A grant revoked or expired before selection no longer authorises a review.
$eligible = $reviews->filter(function ($review) use ($record) {
$user = User::query()->where('tenant_id', $record->tenant_id)->where('id', $review->reviewed_by)->first();
return $user !== null && $user->id !== $record->created_by && $this->allowed($user, $record->kind, 'review') && app(PeopleAccess::class)->can($user, $record, 'review');
});
$score = $eligible->count() < $record->rules['minimum_reviews'] ? null : BigDecimal::of((string) $eligible->sum('score_minor'))->dividedBy($eligible->count(), 0, RoundingMode::HalfUp)->toInt();
$clarifications = app(PeopleClarifications::class)->metadata($record, $application->id);
$source = $electronic->get($application->id);
$rows[] = ['id' => $application->id, 'registered_by' => $application->created_by, 'name' => $application->name, 'reference' => $application->reference, 'received_on' => $application->received_on,
...($clarifications === [] ? [] : ['clarifications' => $clarifications]),
...($source === null ? [] : ['electronic_source' => ['submission_id' => $source->id, 'reference' => $source->reference, 'profile_version' => $source->profile_version]]),
...(isset($application->person_snapshot) ? ['registry_source' => array_intersect_key(json_decode($application->person_snapshot, true, 16, JSON_THROW_ON_ERROR), array_flip(['id', 'reference', 'version']))] : []),
'summary' => $application->summary, 'evaluation_count' => $eligible->count(), 'score_minor' => $score,
'reviews' => $reviews->map(fn ($review) => ['id' => $review->id, 'reviewed_by' => $review->reviewed_by, 'score_minor' => $review->score_minor,
'answers' => json_decode($review->answers, true, 32, JSON_THROW_ON_ERROR), 'reason' => $review->reason, 'created_at' => $review->created_at])->all()];
}
return $rows;
}
/** @param array<string,mixed> $input */
public function execute(User $actor, ?int $id, string $command, array $input): PeopleProcess
{
return DB::transaction(function () use ($actor, $id, $command, $input) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
if ($command === 'create') {
$data = Validator::make($input, ['operation_key' => 'required|uuid', 'kind' => 'required|in:service,staff', 'title' => 'required|string|min:3|max:200',
'purpose' => 'required|string|min:10|max:5000', 'closes_on' => 'required|date_format:Y-m-d|after_or_equal:today', 'minimum_reviews' => 'required|integer|between:1,20',
'criteria' => 'required|array|min:1|max:20', 'criteria.*.label' => 'required|string|max:150|distinct', 'criteria.*.weight' => 'required|integer|between:1,100'])->validate();
abort_unless($this->allowed($actor, $data['kind'], 'prepare'), 403);
if (array_sum(array_column($data['criteria'], 'weight')) !== 100) {
throw ValidationException::withMessages(['criteria' => 'As ponderações dos critérios devem somar 100%.']);
}
$annex = app(PeopleForms::class)->validate($input, $data['kind']);
if ($annex !== null) {
$data['annex'] = $annex;
}
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
$existing = PeopleProcess::query()->where('operation_key', $data['operation_key'])->first();
if ($existing) {
abort_unless($existing->created_by === $actor->id && $existing->creation_hash === $hash, 409);
return $existing;
}
$submissionForm = app(SubmissionForms::class)->opportunity($actor->tenant_id, $data['kind']);
$record = PeopleProcess::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id,
'operation_key' => $data['operation_key'], 'creation_hash' => $hash, 'kind' => $data['kind'], 'title' => $data['title'],
'purpose' => $data['purpose'], 'closes_on' => $data['closes_on'], 'rules' => ['minimum_reviews' => (int) $data['minimum_reviews'],
'criteria' => array_map(fn ($criterion) => ['label' => trim($criterion['label']), 'weight' => (int) $criterion['weight']], $data['criteria']),
...($annex === null ? [] : ['annex' => $annex]), ...($submissionForm === null ? [] : ['submission_form' => $submissionForm])]]);
DB::table('people_access_states')->insert(['tenant_id' => $record->tenant_id, 'people_process_id' => $record->id, 'version' => 1]);
app(PeopleAccess::class)->insert($record, $actor->id, ['view', 'prepare', 'contract', 'manage', ...(! empty($annex['fields']['financial']) ? ['financial'] : [])], 'Preparação assumida ao criar o processo.');
$before = null;
} else {
$record = PeopleProcess::query()->lockForUpdate()->findOrFail($id);
abort_unless(app(PeopleAccess::class)->can($actor, $record, 'view'), 403);
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'reason' => 'required|string|min:10|max:5000'])->validate();
if ((int) $data['version'] !== $record->version) {
throw ValidationException::withMessages(['version' => 'O processo mudou. Actualize antes de continuar.']);
}
$before = $this->snapshot($record);
$review = in_array($command, ['open', 'evaluate', 'confirm'], true);
abort_unless($this->allowed($actor, $record->kind, $review ? 'review' : 'prepare'), 403);
$scope = match ($command) {
'open', 'evaluate' => 'review', 'confirm' => 'approve', 'contract' => 'contract', default => 'prepare'
};
abort_unless(app(PeopleAccess::class)->can($actor, $record, $scope), 403);
if ($command === 'open') {
Validator::make($input, ['no_conflict' => 'accepted'])->validate();
abort_unless($record->status === 'draft' && $record->created_by !== $actor->id, 403, 'A abertura exige conferente distinto do preparador.');
abort_unless($record->closes_on >= today()->toDateString(), 422, 'A data de fecho já passou.');
$record->fill(['status' => 'open', 'opened_by' => $actor->id]);
} elseif ($command === 'apply') {
abort_unless($record->status === 'open', 409);
$application = app(PeopleRegistry::class)->application($actor, $input);
abort_unless($application['received_on'] <= $record->closes_on && today()->toDateString() <= $record->closes_on, 422, 'O prazo de candidaturas terminou.');
$duplicate = DB::table('people_applications')->where('tenant_id', $actor->tenant_id)->where(fn ($q) => $q->where('operation_key', $application['operation_key'])->orWhere(fn ($q) => $q->where('people_process_id', $record->id)->where('reference', $application['reference'])))->exists();
abort_if($duplicate, 409, 'Candidatura já registada.');
if (isset($application['person_id'])) {
abort_if(DB::table('people_applications')->where('tenant_id', $actor->tenant_id)->where('people_process_id', $record->id)->where('person_id', $application['person_id'])->exists(), 409, 'Esta pessoa já tem candidatura neste processo.');
}
DB::table('people_applications')->insert([...$application, 'tenant_id' => $actor->tenant_id, 'people_process_id' => $record->id,
'created_by' => $actor->id, 'created_at' => now(), 'updated_at' => now()]);
$activity = $application;
} elseif ($command === 'close') {
abort_unless($record->status === 'open', 409);
abort_unless(today()->toDateString() >= $record->closes_on, 422, 'Aguarde a data de fecho de candidaturas.');
abort_unless(app(CandidateTriage::class)->pending($record) === 0, 422, 'Conclua a triagem das candidaturas electrónicas recebidas antes de iniciar a avaliação.');
$record->status = 'assessment';
} elseif ($command === 'evaluate') {
abort_unless(app(CandidateTriage::class)->pending($record) === 0, 422, 'Conclua a triagem antes de avaliar.');
abort_unless($record->status === 'assessment' && $actor->id !== $record->created_by, 403, 'A avaliação exige independência do preparador.');
$evaluation = Validator::make($input, ['application_id' => 'required|integer|min:1', 'no_conflict' => 'accepted', 'answers' => 'required|array',
'answers.*.score' => 'required|numeric|decimal:0,2|between:0,10', 'answers.*.evidence' => 'required|string|min:10|max:2000',
'answers.*.evidence_type' => (isset($record->rules['annex']) ? 'required' : 'sometimes').'|in:reported,observed,verified',
'answers.*.clarification' => 'nullable|string|max:2000'])->validate();
$application = DB::table('people_applications')->where('tenant_id', $actor->tenant_id)->where('people_process_id', $record->id)->where('id', $evaluation['application_id'])->first();
abort_unless($application !== null, 404);
abort_if($application->created_by === $actor->id, 403, 'Quem registou a candidatura não a pode avaliar.');
abort_if(DB::table('people_reviews')->where('people_application_id', $application->id)->where('reviewed_by', $actor->id)->exists(), 409);
abort_unless(array_keys($evaluation['answers']) === array_keys($record->rules['criteria']), 422, 'Responda a todos os critérios da versão preservada.');
$score = BigDecimal::zero();
foreach ($record->rules['criteria'] as $index => $criterion) {
$score = $score->plus(BigDecimal::of((string) $evaluation['answers'][$index]['score'])->multipliedBy($criterion['weight']));
}
DB::table('people_reviews')->insert(['tenant_id' => $actor->tenant_id, 'people_application_id' => $application->id,
'reviewed_by' => $actor->id, 'answers' => json_encode($evaluation['answers'], JSON_THROW_ON_ERROR), 'score_minor' => $score->toScale(0, RoundingMode::HalfUp)->toInt(),
'reason' => $data['reason'], 'created_at' => now(), 'updated_at' => now()]);
$activity = $evaluation;
} elseif ($command === 'select') {
abort_unless(app(CandidateTriage::class)->pending($record) === 0, 422, 'Conclua a triagem antes de propor uma selecção.');
abort_unless($record->status === 'assessment', 409);
$choice = Validator::make($input, ['application_id' => 'required|integer|min:1', 'no_conflict' => 'accepted'])->validate();
$rows = $this->matrix($record);
$complete = $rows !== [] && ! in_array(null, array_column($rows, 'score_minor'), true);
abort_unless($complete, 422, 'Avalie todas as candidaturas segundo o mínimo de avaliações elegíveis.');
$top = collect($rows)->max('score_minor');
$selected = collect($rows)->firstWhere('id', (int) $choice['application_id']);
abort_unless($selected && $selected['score_minor'] === $top, 422, 'A escolha deve recair num dos candidatos de maior pontuação; fundamente os empates.');
$record->fill(['status' => 'selection_review', 'selected_application_id' => $selected['id'],
'selection' => ['proposed_by' => $actor->id, 'application_id' => $selected['id'], 'reason' => $data['reason'], 'rows' => $rows, 'rules' => $record->rules, 'created_at' => now()->toIso8601String()]]);
} elseif ($command === 'confirm') {
abort_unless($record->status === 'selection_review', 409);
$choice = Validator::make($input, ['outcome' => 'required|in:approved,returned', 'no_conflict' => 'accepted'])->validate();
$contributors = $this->decisionContributors($record);
abort_if(in_array($actor->id, $contributors, true), 403, 'A decisão exige pessoa distinta do preparador, proponente e avaliadores.');
if ($choice['outcome'] === 'approved') {
abort_unless($record->selection['rows'] === $this->matrix($record), 409, 'A elegibilidade das avaliações mudou; devolva para nova análise.');
}
$record->fill(['status' => $choice['outcome'] === 'approved' ? 'selected' : 'assessment', 'confirmed_by' => $choice['outcome'] === 'approved' ? $actor->id : null,
'selected_application_id' => $choice['outcome'] === 'approved' ? $record->selected_application_id : null,
'selection' => [...$record->selection, 'decision_reason' => $data['reason'], 'decision_by' => $actor->id, 'decision_at' => now()->toIso8601String(), 'outcome' => $choice['outcome']]]);
} elseif ($command === 'contract') {
abort_unless($record->status === 'selected', 409);
$contract = Validator::make($input, ['reference' => 'required|string|max:200', 'starts_on' => 'required|date_format:Y-m-d',
'terms' => 'required|string|min:20|max:10000', 'no_conflict' => 'accepted'])->validate();
$record->fill(['contract' => [...$contract, 'prepared_by' => $actor->id, 'prepared_at' => now()->toIso8601String(), 'kind' => $record->kind, 'status' => 'draft', 'reason' => $data['reason']], 'status' => 'contract_draft']);
} else {
abort(404);
}
$record->version++;
$record->save();
}
app(RequisitionWorkflow::class)->audit($actor, 'people.'.$command, null, $before, [...$this->snapshot($record), 'reason' => $data['reason'] ?? null, 'activity' => $activity ?? null, 'no_conflict' => (bool) ($input['no_conflict'] ?? false)]);
return $record;
});
}
}