Files
Investidor-V2/app/Services/OpportunityProfileWorkflow.php
T

554 lines
41 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
{
private const PROMOTER_ACTIONS = ['submit', 'request_archive', 'appeal', 'request_revision'];
public function __construct(private PortalProfileAccess $access, private OpportunityManagement $management,
private OpportunityCanonicalContract $contract, private OpportunityWorkflowTasks $tasks) {}
public function authorizeEditorialCuration(User $actor, Opportunity $record): void
{
$this->authorizeAction($actor, $record, 'publish');
}
/** @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->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<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' => [], '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<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',
'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<array-key, mixed> $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);
}
}
}
}