Files
Investidor-V2/app/Services/OpportunityProfileWorkflow.php
T
nala 01405cbf8a
tests / ci (push) Canceled after 0s
oportunidades 5
2026-10-10 14:15:28 +02:00

421 lines
31 KiB
PHP

<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class OpportunityProfileWorkflow
{
public function __construct(private PortalProfileAccess $access, private OpportunityManagement $management,
private OpportunityCanonicalContract $contract, private OpportunityWorkflowTasks $tasks) {}
/** @return list<string> */
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->scoped($actor, 'promoter', $record->organization_id, $record->opportunity_category)
&& ($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';
}
}
if ($this->access->mfa($actor) && ! $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 === '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;
}
/** Scoped names/IDs only; no secrets or implicit assignees.
* @return array<string,mixed> */
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' => []];
}
$result = ['validators' => [], 'publishers' => []];
foreach (User::whereNotNull('email_verified_at')->whereNotNull('two_factor_confirmed_at')->get() as $candidate) {
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<string> */
public function submissionGaps(Opportunity $record): array
{
try {
$this->contract->validateSubmission($record);
return [];
} catch (ValidationException $error) {
return array_keys($error->errors());
}
}
/** @param array<string,mixed> $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',
])->validate();
$action = $data['action'];
$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', 'review_gate', 'assign_review' => ['submitted', 'in_review', 'information_pending'],
'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];
$next = $state;
$snapshot = [];
if (in_array($action, ['submit', 'review_gate', 'pending_opinion', 'respond_opinion', 'approve', 'publish'], true)) {
$snapshot = $this->contract->validateSubmission($record);
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') {
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.');
}
}
$context['submission'] = [...$snapshot, ...$decision];
$context['sla'] = ['policy_version' => null, 'due_at' => null, 'status' => 'unconfigured'];
unset($context['approval'], $context['reviews'], $context['assignment'],$context['publication_assignment']);
$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.');
$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') {
$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.');
$context['reviews'][$gate] = [...$decision, 'decision' => $data['gate_decision'], 'hash' => $snapshot['hash'], 'evidence_document_ids' => $documents];
$next = 'in_review';
} elseif ($action === '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'], 422, 'Gate '.$gate.' pendente, rejeitado ou de outra versão.');
$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') {
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'], 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.');
}
if (isset($context['publication'])) {
$context['publication_history'][] = $context['publication'];
}
$context['publication'] = [...$decision, 'hash' => $snapshot['hash'], 'visibility' => $p['visibility'],
'public_fields' => ['title', 'summary', 'description', 'sector_id', 'province', 'district']];
// 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'];
$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];
unset($context['approval'], $context['reviews']);
$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 (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)) {
unset($context['approval'], $context['reviews']);
}
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;
});
}
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->scoped($actor, 'promoter', $record->organization_id, $record->opportunity_category)
&& ($record->organization_id !== null || (int) $record->owner_user_id === $actor->id);
if (in_array($action, ['submit', 'request_archive', 'appeal', 'request_revision'], 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';
abort_unless($this->access->scoped($actor, $role, $record->organization_id, $record->opportunity_category)
&& $this->access->mfa($actor) && $this->access->operational($actor, $operation, $record->id) && ! $this->access->conflict($actor, $record), 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);
}
}
}
}