554 lines
41 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|
|
}
|