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

98 lines
7.0 KiB
PHP

<?php
namespace App\Services;
use App\Models\CandidateAccount;
use App\Models\PeopleProcess;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use stdClass;
class CandidateSubmissions
{
/** @param array<string,mixed> $input */
public function publish(User $actor, int $processId, array $input): void
{
DB::transaction(function () use ($actor, $processId, $input) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
$record = PeopleProcess::query()->where('tenant_id', $actor->tenant_id)->where('id', $processId)->lockForUpdate()->firstOrFail();
abort_unless(app(PeopleHiring::class)->allowed($actor, $record->kind, 'prepare') && app(PeopleAccess::class)->can($actor, $record, 'prepare') && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
$data = Validator::make($input, ['version' => 'required|integer|min:0', 'active' => 'required|boolean', 'description' => 'required|string|min:10|max:5000', 'reason' => 'required|string|min:10|max:4000'])->validate();
$last = $this->publication($record);
if ((int) $data['version'] !== (int) ($last->version ?? 0)) {
throw ValidationException::withMessages(['version' => 'A publicação mudou. Actualize antes de guardar.']);
}
if ($data['active']) {
abort_unless($record->status === 'open' && $record->closes_on >= today()->toDateString(), 422, 'O processo não está aberto.');
$form = $record->rules['submission_form'] ?? null;
abort_unless($form !== null && $form['definition']['available'] && app(CandidateProfiles::class)->portal($actor->tenant_id) !== null, 422, 'Configure o portal e prepare um processo com formulário disponível.');
foreach ($form['definition']['documents'] as $document) {
if ($document['required']) {
throw ValidationException::withMessages(['active' => 'Este formulário exige anexos. A recepção electrónica de anexos ainda está em desenvolvimento; não publique sem esse percurso.']);
}
}
}
$id = DB::table('candidate_opportunity_publications')->insertGetId(['tenant_id' => $actor->tenant_id, 'people_process_id' => $record->id, 'created_by' => $actor->id,
'version' => (int) $data['version'] + 1, 'active' => $data['active'], 'description' => $data['description'], 'reason' => $data['reason'], 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'candidate_opportunity.published', null, $last === null ? null : (array) $last, ['id' => $id, 'process_id' => $record->id, ...$data]);
});
}
public function publication(PeopleProcess $record): ?stdClass
{
return DB::table('candidate_opportunity_publications')->where('tenant_id', $record->tenant_id)->where('people_process_id', $record->id)->latest('version')->first();
}
/** @return array<string,mixed> */
public function opportunity(int $company, int $processId): array
{
$record = PeopleProcess::withoutGlobalScope('tenant')->where('tenant_id', $company)->where('id', $processId)->firstOrFail();
$publication = $this->publication($record);
$form = $record->rules['submission_form'] ?? null;
abort_unless($publication !== null && $publication->active && $record->status === 'open' && $record->closes_on >= today()->toDateString() && $form !== null && $form['definition']['available'], 404);
return ['id' => $record->id, 'title' => $record->title, 'kind' => $record->kind, 'closes_on' => $record->closes_on, 'description' => $publication->description,
'publication_version' => $publication->version, 'form' => $form];
}
/** @param array<string,mixed> $input */
public function submit(CandidateAccount $actor, int $processId, array $input): int
{
return DB::transaction(function () use ($actor, $processId, $input) {
DB::table('tenants')->where('id', $actor->tenant_id)->lockForUpdate()->firstOrFail();
$sessionVersion = $actor->session_version;
$actor = CandidateAccount::query()->where('id', $actor->id)->lockForUpdate()->firstOrFail();
abort_unless($actor->session_version === $sessionVersion, 401);
abort_unless($actor->email_verified_at !== null, 403, 'Confirme o email antes de submeter.');
$data = Validator::make($input, ['operation_key' => 'required|uuid', 'profile_version' => 'required|integer|min:1', 'publication_version' => 'required|integer|min:1', 'answers' => 'present|array', 'documents' => 'sometimes|array|max:0'])->validate();
$hash = hash('sha256', json_encode([$processId, $data], JSON_THROW_ON_ERROR));
$previous = DB::table('candidate_submissions')->where('candidate_account_id', $actor->id)->where('operation_key', $data['operation_key'])->first();
if ($previous !== null) {
abort_unless($previous->people_process_id === $processId && $previous->input_hash === $hash, 409, 'Esta operação já foi utilizada com outros dados.');
return $previous->id;
}
$opportunity = $this->opportunity($actor->tenant_id, $processId);
$profile = app(CandidateProfiles::class)->profile($actor);
if ((int) $data['profile_version'] !== $profile['version'] || (int) $data['publication_version'] !== $opportunity['publication_version']) {
throw ValidationException::withMessages(['version' => 'O perfil ou a publicação mudou. Actualize e confira antes de submeter.']);
}
abort_if(DB::table('candidate_submissions')->where('people_process_id', $processId)->where('candidate_account_id', $actor->id)->exists(), 409, 'Já apresentou candidatura a esta oportunidade.');
foreach ($opportunity['form']['definition']['documents'] as $document) {
abort_if($document['required'], 422, 'Faltam anexos obrigatórios.');
}
$answers = app(SubmissionAnswers::class)->validate($opportunity['form']['definition'], $data['answers']);
$id = DB::table('candidate_submissions')->insertGetId(['tenant_id' => $actor->tenant_id, 'candidate_account_id' => $actor->id, 'people_process_id' => $processId,
'operation_key' => $data['operation_key'], 'reference' => 'CAND-'.strtoupper((string) Str::uuid()), 'input_hash' => $hash,
'snapshot' => json_encode(['opportunity' => $opportunity, 'profile' => $profile, 'email' => $actor->email, 'answers' => $answers, 'documents' => [], 'status' => 'received'], JSON_THROW_ON_ERROR), 'created_at' => now()]);
app(CandidateProfiles::class)->event($actor, 'submission.received', ['submission_id' => $id, 'process_id' => $processId, 'profile_version' => $profile['version']]);
return $id;
});
}
}