authorizeAction($actor, $record, 'publish'); } /** @return list */ public function actions(User $actor, Opportunity $record): array { if ($record->owner_user_id === null || ! $this->access->canView($actor, $record)) { return []; } $actions = []; $state = $this->access->state($record); if ($this->access->promoterRecordScope($actor, $record) && ($record->organization_id !== null || (int) $record->owner_user_id === $actor->id) && $this->access->operational($actor, 'edit', $record->id)) { if (in_array($state, ['draft', 'needs_information'], true)) { $actions[] = 'submit'; } if (in_array($state, ['approved', 'published', 'suspended'], true)) { $actions[] = 'request_revision'; } if (! in_array($state, ['archived', 'rejected'], true)) { $actions[] = 'request_archive'; } if ($state === 'rejected' && empty($record->workflow_context['appeal']['pending'])) { $actions[] = 'appeal'; } } // List scoped capabilities; the page disables execution until MFA is fresh. // authorizeAction still verifies MFA for every review/publication request. if (! $this->access->conflict($actor, $record)) { if ($this->access->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category) && $this->access->operational($actor, 'validate', $record->id)) { if ($state === 'approved' && count(array_filter($record->workflow_context['conditions'] ?? [], fn ($condition) => $condition['status'] === 'open' && $condition['blocking_action'] === 'publish')) > 0) { $actions[] = 'review_gate'; } if ($state === 'submitted') { $actions[] = 'start_review'; } if (in_array($state, ['submitted', 'in_review', 'information_pending'], true)) { array_push($actions, 'request_correction', 'pending_opinion', 'reject', 'review_gate', 'approve'); if ($this->responsibility($actor, $record, 'validation_coordinator')) { $actions[] = 'assign_review'; } } if ($state === 'rejected' && ($record->workflow_context['appeal']['pending'] ?? false) && $this->responsibility($actor, $record, 'appeal_reviewer') && (int) ($record->workflow_context['rejection']['actor_id'] ?? 0) !== $actor->id) { array_push($actions, 'appeal_uphold', 'appeal_reopen'); } if (count(array_filter($record->workflow_context['opinions'] ?? [], fn ($opinion) => ($opinion['recipient_id'] ?? 0) === $actor->id && ($opinion['status'] ?? '') === 'pending')) > 0) { $actions[] = 'respond_opinion'; } } if ($this->access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category)) { if ($state === 'approved' && $this->access->operational($actor, 'publish', $record->id) && (int) ($record->workflow_context['approval']['actor_id'] ?? 0) !== $actor->id) { $actions[] = 'publish'; if ($this->responsibility($actor, $record, 'publication_coordinator')) { $actions[] = 'assign_publication'; } } if ($this->access->operational($actor, 'archive', $record->id)) { if ($record->status === 'published') { $actions[] = 'suspend'; } if ($state !== 'archived') { $actions[] = 'archive'; } } } } if (isset($record->workflow_context['assignment']['assignee_user_id']) && (int) $record->workflow_context['assignment']['assignee_user_id'] !== $actor->id) { $actions = array_values(array_diff($actions, ['start_review', 'request_correction', 'pending_opinion', 'reject', 'review_gate', 'approve'])); } if (isset($record->workflow_context['publication_assignment']['assignee_user_id']) && (int) $record->workflow_context['publication_assignment']['assignee_user_id'] !== $actor->id) { $actions = array_values(array_diff($actions, ['publish'])); } return $actions; } public function reauthenticationRequired(User $actor, Opportunity $record): bool { return ! $this->access->mfa($actor) && array_diff($this->actions($actor, $record), self::PROMOTER_ACTIONS) !== []; } /** Scoped names/IDs only; no secrets or implicit assignees. * @return array */ public function participants(User $actor, Opportunity $record): array { $this->access->authorize($actor, 'view', $record); if (! $this->access->has($actor, 'validator') && ! $this->access->has($actor, 'publisher')) { return ['validators' => [], 'publishers' => [], 'condition_responsibles' => []]; } $result = ['validators' => [], 'publishers' => [], 'condition_responsibles' => []]; foreach (User::whereNotNull('email_verified_at')->whereNotNull('two_factor_confirmed_at')->get() as $candidate) { if ($this->access->canView($candidate, $record) && (($this->access->promoterRecordScope($candidate, $record) && $this->access->operational($candidate, 'edit', $record->id)) || ($this->access->scoped($candidate, 'validator', $record->organization_id, $record->opportunity_category) && $this->access->operational($candidate, 'validate', $record->id)))) { $result['condition_responsibles'][] = ['id' => $candidate->id, 'name' => $candidate->name]; } if ($this->access->conflict($candidate, $record)) { continue; } foreach (['validator' => 'validators', 'publisher' => 'publishers'] as $role => $list) { if ($this->access->scoped($candidate, $role, $record->organization_id, $record->opportunity_category) && $this->access->operational($candidate, $role === 'validator' ? 'validate' : 'publish', $record->id)) { if ($role === 'publisher' && (int) ($record->workflow_context['approval']['actor_id'] ?? 0) === $candidate->id) { continue; } $result[$list][] = ['id' => $candidate->id, 'name' => $candidate->name, 'technical' => $this->responsibility($candidate, $record, 'technical_reviewer'), 'legal' => $this->responsibility($candidate, $record, 'legal_reviewer')]; } } } return $result; } /** @return list */ public function submissionGaps(Opportunity $record): array { try { $this->contract->validateSubmission($record); return []; } catch (ValidationException $error) { return array_keys($error->errors()); } } /** @param array $input */ public function transition(User $actor, int $id, array $input): Opportunity { $record = Opportunity::withTrashed()->findOrFail($id); $this->access->authorize($actor, 'view', $record); abort_unless($record->owner_user_id !== null && ! $record->trashed(), 409, 'Workflow apenas para registos explicitamente sujeitos à política.'); $data = Validator::make($input, ['etag' => 'required|string|size:64', 'action' => 'required|in:submit,start_review,request_correction,pending_opinion,respond_opinion,reject,approve,publish,suspend,archive,request_archive,appeal,request_revision,review_gate,assign_review,assign_publication,appeal_uphold,appeal_reopen', 'reason' => 'required|string|min:20|max:1000', 'correction_items' => 'sometimes|array', 'correction_items.*' => 'required|string|max:250', 'gate' => 'sometimes|string|max:80', 'gate_decision' => 'sometimes|in:approved,rejected', 'assignee_user_id' => 'sometimes|integer|min:1', 'opinion_id' => 'sometimes|uuid', 'recipient_user_id' => 'sometimes|integer|min:1', 'evidence_document_ids' => 'sometimes|array', 'evidence_document_ids.*' => 'uuid', 'due_at' => 'prohibited', 'confirm_publication' => 'sometimes|boolean', 'preview_hash' => 'sometimes|string|size:64', 'condition' => 'sometimes|array:description,responsible_user_id,due_on,blocking_action', 'condition.description' => 'required_with:condition|string|min:10|max:1000', 'condition.responsible_user_id' => 'required_with:condition|integer|min:1', 'condition.due_on' => 'sometimes|nullable|date_format:Y-m-d', 'condition.blocking_action' => 'required_with:condition|in:approve,publish', 'resolve_condition_ids' => 'sometimes|array', 'resolve_condition_ids.*' => 'uuid|distinct', ])->validate(); $action = $data['action']; if ($action !== 'review_gate' && (isset($data['condition']) || ! empty($data['resolve_condition_ids']))) { throw ValidationException::withMessages(['condition' => 'Condições só podem ser verificadas numa revisão especializada autorizada.']); } $this->authorizeAction($actor, $record, $action); return DB::transaction(function () use ($actor, $id, $data, $action) { $record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail(); abort_unless(hash_equals($this->management->etag($record), $data['etag']), 409, 'Versão alterada.'); $this->authorizeAction($actor, $record, $action); $context = $record->workflow_context ?? []; $state = $this->access->state($record); $before = ['status' => $state, 'publication_status' => $record->status, 'version' => $record->management_version]; $from = match ($action) { 'submit' => ['draft', 'needs_information'], 'start_review' => ['submitted'], 'request_correction', 'pending_opinion', 'respond_opinion', 'reject', 'approve', 'assign_review' => ['submitted', 'in_review', 'information_pending'], 'review_gate' => ['submitted', 'in_review', 'information_pending', 'approved'], 'publish' => ['approved'], 'suspend' => [$state], 'assign_publication' => ['approved'], 'request_revision' => ['approved', 'published', 'suspended'], 'archive' => ['draft', 'submitted', 'in_review', 'information_pending', 'needs_information', 'approved', 'rejected', 'published', 'suspended'], 'request_archive' => ['draft', 'submitted', 'in_review', 'information_pending', 'needs_information', 'approved', 'published', 'suspended'], 'appeal', 'appeal_uphold', 'appeal_reopen' => ['rejected'], default => [], }; abort_unless(in_array($state, $from, true), 409, 'Transição incompatível com o estado atual.'); $decision = ['actor_id' => $actor->id, 'action' => $action, 'reason' => $data['reason'], 'at' => now()->toIso8601String(), 'content_revision' => $context['content_revision'] ?? 1]; if (isset($context['review_cycle'])) { $decision['review_cycle'] = $context['review_cycle']; } $next = $state; $snapshot = []; if ($action === 'submit' && $record->opportunity_category === 'land_rights') { // Serialize the existing JSON-backed §27 uniqueness check across records. DB::select('SELECT pg_advisory_xact_lock(111026,270000)'); } if (in_array($action, ['submit', 'review_gate', 'pending_opinion', 'respond_opinion', 'approve', 'publish'], true)) { $blockers = $this->contract->deliveryBlockers($this->management->values($record)['canonical'] ?? []); try { $snapshot = $this->contract->validateSubmission($record); } catch (ValidationException $error) { $errors = $error->errors(); foreach ($blockers as $field => $message) { $errors[$field][] = $message; } throw ValidationException::withMessages($errors); } if ($blockers !== []) { throw ValidationException::withMessages($blockers); } if ($action !== 'submit') { abort_unless(hash_equals($context['submission']['hash'] ?? '', $snapshot['hash']), 409, 'Conteúdo ou recursos mudaram desde a submissão. Exige nova submissão/revisão.'); } } if ($action === 'submit') { // New review attempt, even when the canonical content is unchanged. // Historical decisions/tasks and the content revision keep their IDs. $context['review_cycle'] = max((int) ($context['review_cycle'] ?? 0), $this->submissionCount($context)) + 1; $decision['review_cycle'] = $context['review_cycle']; foreach ($context['opinions'] ?? [] as $opinionId => $opinion) { if (($opinion['status'] ?? '') === 'pending') { $context['opinions'][$opinionId]['status'] = 'superseded'; $this->tasks->complete($opinion['task_id'] ?? null, 'Nova versão submetida; parecer anterior preservado.'); } } $baseline = $context['review_baseline'] ?? $context['submission'] ?? []; $materiality = app(OpportunityReviewMateriality::class)->reconcile($baseline, $snapshot, $context['reviews'] ?? [], $this->contract->gates($record), ! empty($context['force_review'])); $context['materiality'] = array_diff_key($materiality, ['reviews' => true]); $context['reviews'] = $materiality['reviews']; app(OpportunityReviewConditions::class)->reconcile($context, $snapshot, $materiality['reopened']); $context['submission'] = [...$snapshot, ...$decision]; $context['sla'] = ['policy_version' => null, 'due_at' => null, 'status' => 'unconfigured']; unset($context['approval'], $context['assignment'], $context['publication_assignment'], $context['review_baseline'], $context['force_review']); $record->first_submitted_at ??= now()->toIso8601String(); $record->management_closed_once = true; $record->workflow_context = $context; $context['review_task_id'] = $this->tasks->ensure($record, 'opportunity_review', 'Validação da oportunidade '.$record->reference); $this->tasks->complete($context['correction_task_id'] ?? null, $data['reason']); $next = 'submitted'; } elseif ($action === 'start_review' || $action === 'assign_review') { $subject = $action === 'start_review' ? $actor : User::whereKey($data['assignee_user_id'] ?? 0)->firstOrFail(); abort_unless($this->access->scoped($subject, 'validator', $record->organization_id, $record->opportunity_category) && $this->access->operational($subject, 'validate', $record->id) && ! $this->access->conflict($subject, $record) && $subject->two_factor_confirmed_at !== null, 403, 'Responsável requer papel válido, MFA configurado e independência.'); if ($this->tasks->closedReviewBinding($record, $context['review_task_id'] ?? null)) { // Recover only a completed task of this opportunity in an // already submitted legacy attempt. Never reopen/delete it. $oldTask = $context['review_task_id']; $context['review_cycle'] = max(1, (int) ($context['review_cycle'] ?? 0), $this->submissionCount($context)); $record->workflow_context = $context; $context['review_task_id'] = $this->tasks->ensure($record, 'opportunity_review', 'Validação da oportunidade '.$record->reference); $context['review_task_binding_history'][] = ['previous_task_id' => $oldTask, 'task_id' => $context['review_task_id'], 'review_cycle' => $context['review_cycle'], 'at' => now()->toIso8601String(), 'reason' => 'Completed legacy review binding replaced for the current submission; original preserved.']; $decision['review_cycle'] = $context['review_cycle']; } $context['review_task_id'] ??= $this->tasks->ensure($record, 'opportunity_review', 'Validação da oportunidade '.$record->reference); $this->tasks->assign($record, $context['review_task_id'], $subject); $context['assignment'] = [...$decision, 'assignee_user_id' => $subject->id]; $next = 'in_review'; } elseif ($action === 'review_gate') { if ($state === 'approved') { throw_unless(! empty($data['resolve_condition_ids']) && empty($data['condition']) && ($data['gate_decision'] ?? '') === 'approved', ValidationException::withMessages(['resolve_condition_ids' => 'Após aprovação, só verificar condições de publicação existentes; alterações exigem nova revisão.'])); } $gate = $data['gate'] ?? ''; abort_unless(in_array($gate, $this->contract->gates($record), true), 422, 'Gate não aplicável.'); $legal = preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1; abort_unless($this->responsibility($actor, $record, $legal ? 'legal_reviewer' : 'technical_reviewer'), 403, 'Responsabilidade de revisão necessária.'); $documents = $data['evidence_document_ids'] ?? []; abort_unless($documents !== [] && array_diff($documents, array_keys($snapshot['resources']['documents'] ?? [])) === [], 422, 'Selecione evidência documental desta versão.'); abort_unless(isset($data['gate_decision']), 422, 'Indique decisão fundamentada do gate.'); app(OpportunityReviewConditions::class)->review($record, $actor, $data, $snapshot, $context); $context['reviews'][$gate] = [...$decision, 'decision' => $data['gate_decision'], 'hash' => $snapshot['hash'], 'evidence_document_ids' => $documents]; $context['reviews'][$gate]['scope_hash'] = app(OpportunityReviewMateriality::class)->hash($snapshot, $gate, $context['reviews'][$gate]); $context['reviews'][$gate]['scope_rule'] = OpportunityReviewMateriality::VERSION; $next = 'in_review'; } elseif ($action === 'approve') { app(OpportunityReviewConditions::class)->authorize($context, $snapshot, 'approve'); abort_if(count(array_filter($context['opinions'] ?? [], fn ($o) => ($o['hash'] ?? '') === $snapshot['hash'] && ($o['status'] ?? '') === 'pending')) > 0, 422, 'Parecer da versão atual ainda pendente.'); abort_if(count(array_filter($context['opinions'] ?? [], fn ($o) => ($o['hash'] ?? '') === $snapshot['hash'] && ($o['response']['decision'] ?? '') === 'rejected')) > 0, 422, 'Parecer desfavorável exige correção e nova versão.'); foreach ($this->contract->gates($record) as $gate) { $review = $context['reviews'][$gate] ?? []; abort_unless(($review['decision'] ?? null) === 'approved' && (($review['hash'] ?? '') === $snapshot['hash'] || ($review['accepted_submission_hash'] ?? '') === $snapshot['hash']), 422, 'Gate '.$gate.' pendente, rejeitado ou de outra versão.'); abort_unless(app(OpportunityReviewMateriality::class)->matches($snapshot, $gate, $review), 409, 'Âmbito da revisão alterado.'); $reviewer = User::whereKey($review['actor_id'])->firstOrFail(); abort_unless($this->access->scoped($reviewer, 'validator', $record->organization_id, $record->opportunity_category) && ! $this->access->conflict($reviewer, $record) && $this->access->operational($reviewer, 'validate', $record->id) && $this->responsibility($reviewer, $record, preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1 ? 'legal_reviewer' : 'technical_reviewer'), 403, 'Revisor revogado, sem responsabilidade ou com conflito.'); } $context['approval'] = [...$decision, 'hash' => $snapshot['hash'], 'gates' => $context['reviews'] ?? []]; $this->tasks->complete($context['review_task_id'] ?? null, $data['reason']); $record->workflow_context = $context; $context['publication_task_id'] = $this->tasks->ensure($record, 'opportunity_publication', 'Preparação editorial '.$record->reference); $next = 'approved'; } elseif ($action === 'assign_publication') { $subject = User::whereKey($data['assignee_user_id'] ?? 0)->firstOrFail(); abort_unless($this->access->scoped($subject, 'publisher', $record->organization_id, $record->opportunity_category) && $this->access->operational($subject, 'publish', $record->id) && ! $this->access->conflict($subject, $record) && $subject->two_factor_confirmed_at !== null && (int) ($context['approval']['actor_id'] ?? 0) !== $subject->id, 403, 'Publicador exige MFA, âmbito e independência da aprovação.'); $context['publication_task_id'] ??= $this->tasks->ensure($record, 'opportunity_publication', 'Preparação editorial '.$record->reference); $this->tasks->assign($record, $context['publication_task_id'], $subject); $context['publication_assignment'] = [...$decision, 'assignee_user_id' => $subject->id]; } elseif ($action === 'publish') { app(OpportunityReviewConditions::class)->authorize($context, $snapshot, 'publish'); Validator::make($data, ['confirm_publication' => 'required|accepted', 'preview_hash' => 'required|string|size:64'])->validate(); $this->contract->validatePublication($snapshot); abort_unless(hash_equals($snapshot['hash'], $data['preview_hash']), 409, 'Pré-visualização diferente da versão aprovada.'); abort_unless(($context['approval']['hash'] ?? '') === $snapshot['hash'], 409, 'Aprovação não corresponde ao conteúdo atual.'); foreach ($this->contract->gates($record) as $gate) { $review = $context['approval']['gates'][$gate] ?? []; abort_unless(($review['decision'] ?? null) === 'approved' && (($review['hash'] ?? '') === $snapshot['hash'] || ($review['accepted_submission_hash'] ?? '') === $snapshot['hash']), 422, 'Revisão aprovada desta versão necessária.'); $reviewer = User::whereKey($review['actor_id'])->firstOrFail(); abort_unless($this->access->scoped($reviewer, 'validator', $record->organization_id, $record->opportunity_category) && ! $this->access->conflict($reviewer, $record) && $this->access->operational($reviewer, 'validate', $record->id) && $this->responsibility($reviewer, $record, preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1 ? 'legal_reviewer' : 'technical_reviewer'), 403, 'Revisor revogado, sem responsabilidade ou com conflito na publicação.'); } $approver = User::whereKey($context['approval']['actor_id'] ?? 0)->firstOrFail(); abort_unless($this->access->scoped($approver, 'validator', $record->organization_id, $record->opportunity_category) && ! $this->access->conflict($approver, $record) && $this->access->operational($approver, 'validate', $record->id), 403, 'Aprovador revogado ou com conflito.'); $p = $snapshot['payload']; if (! empty($p['review_due_date'])) { abort_unless($p['review_due_date'] > today()->toDateString(), 422, 'Data de revisão deve ser futura.'); } if (isset($p['business_transaction'])) { abort_unless(in_array($p['business_transaction']['due_diligence_status'], ['data_room_ready', 'in_progress', 'completed', 'exceptions'], true), 422, 'Data room mínimo não preparado.'); } $previousMaturity = $context['publication']['maturity'] ?? null; if (isset($context['publication'])) { $context['publication_history'][] = [...$context['publication'], 'public_maturity_before_republication' => ['level' => $record->maturity_level, 'label' => $record->maturity_label, 'catalogue_version' => $context['publication']['maturity']['catalogue_version'] ?? null]]; } $context['publication'] = [...$decision, 'hash' => $snapshot['hash'], 'visibility' => $p['visibility'], 'public_fields' => ['title', 'summary', 'description', 'sector_id', 'province', 'district'], 'classification' => app(OpportunityClassification::class)->publication($p), 'taxonomy' => array_intersect_key($p, array_flip(['subsector_id', 'economic_activity_id']))]; // Minimal public projection; contacts, valuations, evidence and originals remain private. $projection = $this->contract->publicProjection($p); $record->title = $projection['title']; $record->summary = $projection['summary']; $record->description = $projection['description']; $record->sector_id = $p['sector_id']; if (! empty($p['maturity_stage'])) { // Project only the explicitly selected, approved V2 stage. // Existing legacy positions/labels are retained in publication history. $maturity = app(OpportunityMaturity::class)->normalize(['maturity_stage' => $p['maturity_stage'], 'maturity_catalogue_version' => OpportunityMaturity::VERSION]); $record->maturity_level = $maturity['maturity_level']; $record->maturity_label = $maturity['maturity_label']; $context['publication']['maturity'] = ['catalogue_version' => $maturity['maturity_catalogue_version'], 'stage' => $maturity['maturity_stage'], 'level' => $maturity['maturity_level'], 'label' => $maturity['maturity_label']]; } elseif (array_key_exists('maturity_stage', $p)) { // An explicitly cleared optional answer must not retain an old // public number or make that number look like a legacy level. $record->maturity_level = null; $record->maturity_label = null; $context['publication']['maturity'] = ['catalogue_version' => OpportunityMaturity::VERSION, 'stage' => null, 'level' => null, 'label' => null]; } elseif ($previousMaturity !== null) { // No answer was supplied: keep the version with the retained // public values, including records prepared by older editors. $context['publication']['maturity'] = $previousMaturity; } $locations = array_values(array_filter($snapshot['resources']['locations'], fn ($location) => $location['payload']['is_primary'] ?? false)); $location = $locations[0]; // The contract requires exactly one primary location. $path = app(InvestmentGeography::class)->selection($location['payload'], true); $last = array_key_last($path); abort_unless($last !== null, 422, 'Localização principal sem hierarquia.'); $record->primary_territory_id = $path[$last]->id; $record->province = $path[0]->name; $record->district = $path[1]->name; $record->geography_scope = $p['geographic_scope'] === 'national' ? 'national' : 'specific'; $record->published_at = now(); $record->status = 'published'; $next = 'published'; $this->tasks->complete($context['publication_task_id'] ?? null, $data['reason']); } elseif ($action === 'pending_opinion') { $gate = $data['gate'] ?? ''; abort_unless(in_array($gate, $this->contract->gates($record), true), 422, 'Indique a revisão aplicável ao parecer.'); $subject = User::whereKey($data['recipient_user_id'] ?? 0)->firstOrFail(); abort_unless($this->access->scoped($subject, 'validator', $record->organization_id, $record->opportunity_category) && $this->access->operational($subject, 'validate', $record->id) && ! $this->access->conflict($subject, $record) && $subject->two_factor_confirmed_at !== null && $this->responsibility($subject, $record, preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1 ? 'legal_reviewer' : 'technical_reviewer'), 403, 'Destinatário exige responsabilidade, MFA e independência.'); abort_if($subject->id === $actor->id, 422, 'O parecer deve ser solicitado a outro revisor.'); $opinionId = (string) Str::uuid(); $taskId = $this->tasks->ensure($record, 'opportunity_opinion:'.$opinionId, 'Parecer da oportunidade '.$record->reference); $this->tasks->assign($record, $taskId, $subject); $context['opinions'][$opinionId] = [...$decision, 'id' => $opinionId, 'recipient_id' => $subject->id, 'gate' => $gate, 'status' => 'pending', 'hash' => $snapshot['hash'], 'task_id' => $taskId]; $next = 'information_pending'; } elseif ($action === 'respond_opinion') { $opinionId = $data['opinion_id'] ?? ''; $opinion = $context['opinions'][$opinionId] ?? []; abort_unless(($opinion['recipient_id'] ?? 0) === $actor->id, 403, 'Parecer atribuído a outro revisor.'); abort_unless($this->responsibility($actor, $record, preg_match('/legal|registry|authority|confidentiality|license/', $opinion['gate'] ?? '') === 1 ? 'legal_reviewer' : 'technical_reviewer'), 403, 'Responsabilidade do parecer revogada.'); abort_unless(($opinion['status'] ?? '') === 'pending' && ($opinion['hash'] ?? '') === $snapshot['hash'], 409, 'Parecer encerrado ou de outra versão.'); $documents = $data['evidence_document_ids'] ?? []; abort_unless(isset($data['gate_decision']) && $documents !== [] && array_diff($documents, array_keys($snapshot['resources']['documents'] ?? [])) === [], 422, 'Resposta exige decisão e documentos desta versão.'); $context['opinions'][$opinionId]['status'] = 'answered'; $context['opinions'][$opinionId]['response'] = [...$decision, 'decision' => $data['gate_decision'], 'evidence_document_ids' => $documents]; $this->tasks->complete($opinion['task_id'], $data['reason']); $next = count(array_filter($context['opinions'], fn ($o) => ($o['hash'] ?? '') === $snapshot['hash'] && ($o['status'] ?? '') === 'pending')) > 0 ? 'information_pending' : 'in_review'; } elseif ($action === 'request_correction') { abort_unless($data['correction_items'] ?? [], 422, 'Indique campos e documentos a corrigir.'); $this->tasks->complete($context['review_task_id'] ?? null, $data['reason']); $context['correction_items'] = $data['correction_items']; $context['correction_cycles'] = ($context['correction_cycles'] ?? 0) + 1; $record->workflow_context = $context; $context['correction_task_id'] = $this->tasks->ensure($record, 'opportunity_correction', 'Correções da oportunidade '.$record->reference); $next = 'needs_information'; } elseif ($action === 'request_revision' || $action === 'appeal_reopen') { $this->tasks->complete($context['review_task_id'] ?? null, $data['reason']); $this->tasks->complete($context['publication_task_id'] ?? null, $data['reason']); $context['decision_history'][] = ['approval' => $context['approval'] ?? null, 'reviews' => $context['reviews'] ?? null, 'submission' => $context['submission'] ?? null]; $context['review_baseline'] = $context['submission'] ?? []; $context['force_review'] = $action === 'appeal_reopen' || $state === 'suspended'; unset($context['approval']); $next = 'needs_information'; } elseif ($action === 'request_archive') { $context['request_archive'] = $decision; } elseif ($action === 'appeal') { abort_if($context['appeal']['pending'] ?? false, 409, 'Recurso já pendente.'); $context['appeal'] = [...$decision, 'pending' => true]; $context['appeal_task_id'] = $this->tasks->ensure($record, 'opportunity_appeal', 'Recurso da oportunidade '.$record->reference); } elseif ($action === 'appeal_uphold') { $next = 'rejected'; } else { $next = match ($action) { 'reject' => 'rejected', 'suspend' => 'suspended', 'archive' => 'archived' }; if ($action === 'reject') { $context['rejection'] = $decision; $this->tasks->complete($context['review_task_id'] ?? null, $data['reason']); } if ($action === 'archive') { $context['archive'] = $decision; } if (in_array($action, ['suspend', 'archive'], true)) { $record->status = $next; foreach (['review_task_id', 'correction_task_id', 'publication_task_id', 'appeal_task_id'] as $task) { $this->tasks->complete($context[$task] ?? null, $data['reason']); } } } if (in_array($action, ['appeal_uphold', 'appeal_reopen'], true)) { $context['appeal']['pending'] = false; $context['appeal']['decision'] = $decision; $this->tasks->complete($context['appeal_task_id'] ?? null, $data['reason']); } if (in_array($action, ['request_correction', 'reject'], true)) { if ($action === 'reject') { $context['force_review'] = true; } unset($context['approval']); } if (in_array($action, ['request_correction', 'reject', 'suspend', 'archive', 'request_revision', 'appeal_reopen'], true)) { foreach ($context['opinions'] ?? [] as $opinionId => $opinion) { if (($opinion['status'] ?? '') === 'pending') { $context['opinions'][$opinionId]['status'] = 'superseded'; $this->tasks->complete($opinion['task_id'] ?? null, $data['reason']); } } } $context['working_state'] = $next; $context['last_decision'] = $decision; $context['events'][] = [...$decision, 'from' => $state, 'to' => $next]; if ($record->status !== 'published' || in_array($action, ['suspend', 'archive'], true)) { $record->status = $next; } $record->management_version++; $record->workflow_context = $context; $record->save(); $this->access->audit($actor, 'workflow_'.$action, ['record_id' => $record->id, 'before' => $before, 'after' => ['status' => $next, 'publication_status' => $record->status, 'version' => $record->management_version], 'reason' => $data['reason']]); return $record; }); } /** @param array $context */ private function submissionCount(array $context): int { $events = $context['events'] ?? []; abort_unless(is_array($events), 409, 'Histórico de submissões inconsistente; preservar e recuperar antes de continuar.'); $count = 0; foreach ($events as $event) { if (is_array($event) && ($event['action'] ?? null) === 'submit') { $count++; } } return $count; } private function responsibility(User $actor, Opportunity $record, string $name): bool { foreach ($this->access->grants($actor, $name === 'publication_coordinator' ? 'publisher' : 'validator') as $grant) { if (($grant->organization_id === null || $grant->organization_id === $record->organization_id) && in_array($record->opportunity_category, json_decode($grant->opportunity_types, true), true) && in_array($name, json_decode($grant->responsibilities, true), true)) { return true; } } return false; } private function authorizeAction(User $actor, Opportunity $record, string $action): void { $this->access->authorize($actor, 'view', $record); $promoter = $this->access->promoterRecordScope($actor, $record) && ($record->organization_id !== null || (int) $record->owner_user_id === $actor->id); if (in_array($action, self::PROMOTER_ACTIONS, true)) { abort_unless($promoter && $this->access->operational($actor, 'edit', $record->id), 403); } else { $role = in_array($action, ['publish', 'assign_publication', 'suspend', 'archive'], true) ? 'publisher' : 'validator'; $operation = $role === 'publisher' ? (in_array($action, ['publish', 'assign_publication'], true) ? 'publish' : 'archive') : 'validate'; $eligible = $this->access->scoped($actor, $role, $record->organization_id, $record->opportunity_category) && $this->access->operational($actor, $operation, $record->id) && ! $this->access->conflict($actor, $record); if ($eligible && ! $this->access->mfa($actor)) { abort(403, 'A confirmação MFA desta janela precisa de renovação. Entre novamente nesta janela com o seu autenticador; os dados já guardados foram preservados.', ['X-Portal-Reauthentication' => 'required']); } abort_unless($eligible && $this->access->mfa($actor), 403, 'Papel, MFA e independência necessários.'); if ($action === 'publish') { abort_if((int) ($record->workflow_context['approval']['actor_id'] ?? 0) === $actor->id, 403, 'O aprovador não publica a mesma versão.'); abort_if(isset($record->workflow_context['publication_assignment']['assignee_user_id']) && (int) $record->workflow_context['publication_assignment']['assignee_user_id'] !== $actor->id, 403, 'Publicação atribuída a outro Publicador; requer reatribuição explícita.'); } if ($action === 'assign_publication') { abort_unless($this->responsibility($actor, $record, 'publication_coordinator'), 403); } if ($action === 'assign_review') { abort_unless($this->responsibility($actor, $record, 'validation_coordinator'), 403); } if (in_array($action, ['start_review', 'review_gate', 'request_correction', 'pending_opinion', 'reject', 'approve'], true) && isset($record->workflow_context['assignment']['assignee_user_id'])) { abort_unless((int) $record->workflow_context['assignment']['assignee_user_id'] === $actor->id, 403, 'Tarefa atribuída a outro Validador; o coordenador deve registar a reatribuição.'); } if (in_array($action, ['appeal_uphold', 'appeal_reopen'], true)) { abort_unless($this->responsibility($actor, $record, 'appeal_reviewer') && ($record->workflow_context['appeal']['pending'] ?? false) && (int) ($record->workflow_context['rejection']['actor_id'] ?? 0) !== $actor->id, 403, 'Recurso exige instância independente da rejeição.'); } if ($action === 'suspend') { abort_unless($record->status === 'published', 409); } } } }