feat: integrate private offers with existing editorial workflow
This commit is contained in:
@@ -105,7 +105,9 @@ final class PortalEditorialWorkflowController extends Controller
|
||||
}
|
||||
$items = $query->orderByDesc('e.updated_at')->paginate(25, ['r.id', DB::raw("e.payload->'resource'->>'title' AS title"), 'r.document_type', 'r.published', 'e.id as edition_id', 'e.revision_no', 'e.state', 'e.legal_required', 'u.name as responsible'])->withQueryString();
|
||||
$destinations = app(ContentManagement::class)->access($actor);
|
||||
$data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $destinations['opportunities']['index'] ?? null, 'opportunityPending' => $workflow->opportunityPending($actor), 'clustersUrl' => $destinations['clusters']['index'] ?? null,
|
||||
$data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $destinations['opportunities']['index'] ?? null,
|
||||
'offersUrl' => isset($destinations['opportunities']['index']) && app(PortalProfileAccess::class)->available() ? '/minhas-oportunidades/ofertas' : null,
|
||||
'opportunityPending' => $workflow->opportunityPending($actor), 'clustersUrl' => $destinations['clusters']['index'] ?? null,
|
||||
'portfolioUrl' => $destinations['portfolio']['index'] ?? null, 'portfolioPending' => $workflow->portfolioPending($actor), 'demographyUrl' => $destinations['demography']['index'] ?? null, 'demographyPending' => $workflow->demographyPending($actor),
|
||||
'territorialUrl' => app(TerritorialRegistry::class)->editorialAvailable() && $actor->can('manage-portal') ? '/management/territorial/editorial' : null,
|
||||
'nativePending' => app(PortalEditorialNativeNotices::class)->pending($actor), 'zonesUrl' => app(PortalEditorialNativeNotices::class)->access($actor, 'investment-zones') ? '/management/investment/zones' : null,
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\OpportunityCanonicalContract;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityProfileWorkflow;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\PrivateOfferPreparation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -15,13 +17,15 @@ final class PrivateOfferPreparationController extends Controller
|
||||
{
|
||||
public function index(Request $request, PortalProfileAccess $access): Response
|
||||
{
|
||||
$filters = $request->validate(['state' => 'nullable|in:draft,submitted,in_review,needs_information,rejected,approved']);
|
||||
$records = $access->scope(Opportunity::query(), $request->user())->whereNotNull('workflow_context->private_offer_preparation')
|
||||
->orderByDesc('id')->paginate(20);
|
||||
->when($filters['state'] ?? null, fn ($q, $state) => $q->where('workflow_context->private_offer_preparation->state', $state))
|
||||
->orderByDesc('id')->paginate(20)->withQueryString();
|
||||
|
||||
return Inertia::render('offers/preparation-index', ['records' => $records->through(fn ($r) => [
|
||||
'id' => $r->id, 'title' => $r->title, 'reference' => $r->reference,
|
||||
'state' => data_get($r->workflow_context, 'private_offer_preparation.state'),
|
||||
]), 'canCreate' => count($access->navigation($request->user())['contexts']) > 0]);
|
||||
'id' => $r->id, 'title' => app(OpportunityManagement::class)->values($r)['title'] ?? null, 'reference' => $r->reference,
|
||||
'state' => $access->state($r),
|
||||
]), 'filters' => $filters, 'canCreate' => count($access->navigation($request->user())['contexts']) > 0]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, PrivateOfferPreparation $service, ?int $id = null): Response|JsonResponse
|
||||
@@ -56,4 +60,15 @@ final class PrivateOfferPreparationController extends Controller
|
||||
return response()->json(['record' => $service->presentation($request->user(), $record),
|
||||
'message' => 'Submissão privada registada. A oferta não foi publicada.']);
|
||||
}
|
||||
|
||||
public function transition(Request $request, PrivateOfferPreparation $service, OpportunityProfileWorkflow $workflow, int $id): JsonResponse
|
||||
{
|
||||
$service->record($request->user(), $id);
|
||||
$data = $request->validate(['etag' => 'required|string|size:64', 'action' => 'required|in:start_review,assign_review,request_correction,reject,approve,request_revision',
|
||||
'assignee_user_id' => 'required_if:action,assign_review|integer|min:1',
|
||||
'reason' => 'required|string|min:20|max:1000', 'correction_items' => 'sometimes|array', 'correction_items.*' => 'string']);
|
||||
$record = $workflow->transition($request->user(), $id, $data);
|
||||
|
||||
return response()->json(['record' => $service->presentation($request->user(), $record), 'message' => 'Decisão privada registada. A oferta não foi publicada.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Services\PortalEditorialWorkflow;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/** Existing email transport and operational allowlist; no confidential payload. */
|
||||
final class NotifyPrivateOfferDecision implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 30;
|
||||
|
||||
public function __construct(public string $noticeId) {}
|
||||
|
||||
/** @return list<int> */
|
||||
public function backoff(): array
|
||||
{
|
||||
return [60, 300, 900];
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! config('portal.notifications_enabled')) {
|
||||
return;
|
||||
}
|
||||
$notice = DB::table('portal_editorial_notifications')->where('id', $this->noticeId)->first();
|
||||
$user = $notice ? User::query()->whereKey($notice->user_id)->first() : null;
|
||||
$record = $notice ? Opportunity::query()->whereKey($notice->opportunity_id)->first() : null;
|
||||
if (! $notice || ! $user || ! $record?->isPrivateOfferPreparation()
|
||||
|| ! in_array($user->email, config('portal.notification_recipients', []), true)
|
||||
|| ! app(PortalEditorialWorkflow::class)->visibleNotice($user, $notice)) {
|
||||
return;
|
||||
}
|
||||
$body = 'Existe uma actualização na revisão privada da oferta '.$record->reference.'. Consulte a decisão e a versão na área protegida: '.url('/minhas-oportunidades/ofertas/'.$record->id);
|
||||
Mail::raw($body, fn ($message) => $message->to($user->email)->subject('Actualização da oferta — '.$record->reference));
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,12 @@ class OpportunityManagement
|
||||
$record->management_closed_once = true;
|
||||
$previous = $record->management_draft ? json_decode($record->management_draft, true) : $record->only(self::FIELDS);
|
||||
$record->management_draft = json_encode([...$previous, ...$changes], JSON_THROW_ON_ERROR);
|
||||
if ($privateOffer) {
|
||||
// All columns of an Offer remain private; keep scope/category and location current.
|
||||
foreach (array_intersect_key($changes, array_flip(self::FIELDS)) as $field => $value) {
|
||||
$record->{$field} = $value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($changes as $key => $value) {
|
||||
if (in_array($key, ['real_estate', 'canonical', 'proactive', 'private_offer', 'maturity_stage', 'maturity_catalogue_version'], true) || (($changes['maturity_catalogue_version'] ?? null) === OpportunityMaturity::VERSION && in_array($key, ['maturity_level', 'maturity_label'], true))) {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -24,9 +25,12 @@ class OpportunityProfileWorkflow
|
||||
/** @return list<string> */
|
||||
public function actions(User $actor, Opportunity $record): array
|
||||
{
|
||||
if ($record->isPrivateOfferPreparation() || $record->owner_user_id === null || ! $this->access->canView($actor, $record)) {
|
||||
if ($record->owner_user_id === null || ! $this->access->canView($actor, $record)) {
|
||||
return [];
|
||||
}
|
||||
if ($record->isPrivateOfferPreparation()) {
|
||||
return $this->offerActions($actor, $record);
|
||||
}
|
||||
$actions = [];
|
||||
$state = $this->access->state($record);
|
||||
if ($this->access->promoterRecordScope($actor, $record)
|
||||
@@ -99,6 +103,42 @@ class OpportunityProfileWorkflow
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/** Existing roles and transition engine, bounded to the private offer contract.
|
||||
* @return list<string> */
|
||||
private function offerActions(User $actor, Opportunity $record): array
|
||||
{
|
||||
if (! app(PortalEditorialWorkflow::class)->available()) {
|
||||
return [];
|
||||
}
|
||||
$state = $this->access->state($record);
|
||||
$actions = [];
|
||||
if ($this->access->promoterRecordScope($actor, $record) && $this->access->operational($actor, 'edit', $record->id)) {
|
||||
if (in_array($state, ['draft', 'needs_information'], true)) {
|
||||
$actions[] = 'submit';
|
||||
}
|
||||
if (in_array($state, ['rejected', 'approved'], true)) {
|
||||
$actions[] = 'request_revision';
|
||||
}
|
||||
}
|
||||
if ($this->access->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category)
|
||||
&& $this->access->operational($actor, 'validate', $record->id) && ! $this->access->conflict($actor, $record)) {
|
||||
if (in_array($state, ['submitted', 'in_review'], true) && $this->responsibility($actor, $record, 'validation_coordinator')) {
|
||||
$actions[] = 'assign_review';
|
||||
}
|
||||
$assigned = data_get($record->workflow_context, 'assignment.assignee_user_id');
|
||||
if ($assigned === null || (int) $assigned === $actor->id) {
|
||||
if ($state === 'submitted') {
|
||||
$actions[] = 'start_review';
|
||||
}
|
||||
if ($state === 'in_review') {
|
||||
array_push($actions, 'request_correction', 'reject', 'approve');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function reauthenticationRequired(User $actor, Opportunity $record): bool
|
||||
{
|
||||
return ! $this->access->mfa($actor)
|
||||
@@ -178,6 +218,7 @@ class OpportunityProfileWorkflow
|
||||
abort_unless(hash_equals($this->management->etag($record), $data['etag']), 409, 'Versão alterada.');
|
||||
$this->authorizeAction($actor, $record, $action);
|
||||
$context = $record->workflow_context ?? [];
|
||||
$offer = $record->isPrivateOfferPreparation();
|
||||
if ($action === 'submit' && app(PortalEditorialWorkflow::class)->available()) {
|
||||
$context['editorial_version_binding'] = 'phase1-exact-v1';
|
||||
$record->workflow_context = $context;
|
||||
@@ -191,7 +232,7 @@ class OpportunityProfileWorkflow
|
||||
'review_gate' => ['submitted', 'in_review', 'information_pending', 'approved'],
|
||||
'publish' => ['approved'], 'suspend' => [$state],
|
||||
'assign_publication' => ['approved'],
|
||||
'request_revision' => ['approved', 'published', 'suspended'],
|
||||
'request_revision' => $offer ? ['approved', 'rejected'] : ['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'],
|
||||
@@ -205,11 +246,35 @@ class OpportunityProfileWorkflow
|
||||
}
|
||||
$next = $state;
|
||||
$snapshot = [];
|
||||
if ($action === 'submit' && $record->opportunity_category === 'land_rights') {
|
||||
if ($offer) {
|
||||
$snapshot = in_array($action, ['request_revision', 'request_correction'], true)
|
||||
? ($context['submission'] ?? []) : app(PrivateOfferPreparation::class)->snapshot($record);
|
||||
// Explicit first review of an immutable phase-1 submission; no writes on read.
|
||||
if (in_array($action, ['start_review', 'assign_review'], true) && empty($context['submission'])) {
|
||||
abort_unless(($context['private_offer_preparation']['version'] ?? null) === 1
|
||||
&& $record->management_version === (int) ($context['private_offer_preparation']['submitted_revision'] ?? -2) + 1, 409, 'Reconcilie a submissão privada anterior.');
|
||||
$snapshot['record_version'] = $context['private_offer_preparation']['submitted_revision'];
|
||||
$context['submission'] = [...$snapshot, 'actor_id' => $context['private_offer_preparation']['submitted_by'], 'at' => $context['private_offer_preparation']['submitted_at']];
|
||||
$context['phase1_binding'] = ['bound_at' => $decision['at'], 'bound_by' => $actor->id, 'hash' => $snapshot['hash'], 'original_reference_fingerprint' => 'not_recorded'];
|
||||
$context['review_cycle'] = 1;
|
||||
$decision['review_cycle'] = 1;
|
||||
$context['phase1_submission'] = $context['private_offer_preparation'];
|
||||
$record->first_submitted_at ??= $context['private_offer_preparation']['submitted_at'];
|
||||
$record->management_closed_once = true;
|
||||
}
|
||||
if ($action !== 'submit') {
|
||||
abort_unless(hash_equals($context['submission']['hash'] ?? '', $snapshot['hash']), 409, 'A oferta ou as referências mudaram; prepare nova revisão antes da decisão.');
|
||||
}
|
||||
$decision['hash'] = $snapshot['hash'];
|
||||
$decision['opportunity_version'] = $record->management_version;
|
||||
$decision['submitted_revision'] = $action === 'submit' ? $snapshot['record_version'] : ($context['submission']['record_version'] ?? $record->management_version);
|
||||
$record->workflow_context = $context;
|
||||
}
|
||||
if (! $offer && $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)) {
|
||||
if (! $offer && 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);
|
||||
@@ -228,6 +293,12 @@ class OpportunityProfileWorkflow
|
||||
}
|
||||
}
|
||||
if ($action === 'submit') {
|
||||
if ($offer && isset($context['submission'])) {
|
||||
$context['submission_history'][] = ['submission' => $context['submission'], 'approval' => $context['approval'] ?? null, 'rejection' => $context['rejection'] ?? null];
|
||||
}
|
||||
if ($offer) {
|
||||
unset($context['rejection'], $context['correction_items']);
|
||||
}
|
||||
// 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;
|
||||
@@ -239,7 +310,7 @@ class OpportunityProfileWorkflow
|
||||
}
|
||||
}
|
||||
$baseline = $context['review_baseline'] ?? $context['submission'] ?? [];
|
||||
$materiality = app(OpportunityReviewMateriality::class)->reconcile($baseline, $snapshot, $context['reviews'] ?? [], $this->contract->gates($record), ! empty($context['force_review']), $strictEditorial);
|
||||
$materiality = app(OpportunityReviewMateriality::class)->reconcile($baseline, $snapshot, $context['reviews'] ?? [], $offer ? [] : $this->contract->gates($record), ! empty($context['force_review']), $strictEditorial);
|
||||
$context['materiality'] = array_diff_key($materiality, ['reviews' => true]);
|
||||
$context['reviews'] = $materiality['reviews'];
|
||||
app(OpportunityReviewConditions::class)->reconcile($context, $snapshot, $materiality['reopened']);
|
||||
@@ -270,6 +341,11 @@ class OpportunityProfileWorkflow
|
||||
}
|
||||
$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);
|
||||
if ($offer) {
|
||||
$decision['previous_assignee_user_id'] = $context['assignment']['assignee_user_id'] ?? null;
|
||||
$decision['assignee_user_id'] = $subject->id;
|
||||
$context['assignment_history'][] = $decision;
|
||||
}
|
||||
$context['assignment'] = [...$decision, 'assignee_user_id' => $subject->id];
|
||||
$next = 'in_review';
|
||||
} elseif ($action === 'review_gate') {
|
||||
@@ -292,7 +368,7 @@ class OpportunityProfileWorkflow
|
||||
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) {
|
||||
foreach ($offer ? [] : $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, $strictEditorial), 409, 'Âmbito ou versão da revisão alterado.');
|
||||
@@ -304,7 +380,9 @@ class OpportunityProfileWorkflow
|
||||
$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);
|
||||
if (! $offer) {
|
||||
$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();
|
||||
@@ -414,6 +492,9 @@ class OpportunityProfileWorkflow
|
||||
$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.');
|
||||
if ($offer) {
|
||||
abort_if(array_diff($data['correction_items'], ['title', 'description', 'opportunity_category', 'private_offer', 'contact', 'location']) !== [], 422, 'Seleccione campos desta oferta.');
|
||||
}
|
||||
$this->tasks->complete($context['review_task_id'] ?? null, $data['reason']);
|
||||
$context['correction_items'] = $data['correction_items'];
|
||||
$context['correction_cycles'] = ($context['correction_cycles'] ?? 0) + 1;
|
||||
@@ -423,7 +504,7 @@ class OpportunityProfileWorkflow
|
||||
} 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['decision_history'][] = ['approval' => $context['approval'] ?? null, 'reviews' => $context['reviews'] ?? null, 'submission' => $context['submission'] ?? null, ...($offer ? ['rejection' => $context['rejection'] ?? null] : [])];
|
||||
$context['review_baseline'] = $context['submission'] ?? [];
|
||||
$context['force_review'] = $action === 'appeal_reopen' || $state === 'suspended';
|
||||
unset($context['approval']);
|
||||
@@ -476,7 +557,14 @@ class OpportunityProfileWorkflow
|
||||
$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)) {
|
||||
if ($offer) {
|
||||
$context['private_offer_preparation'] = [...$context['private_offer_preparation'], 'version' => 2, 'state' => $next];
|
||||
if ($action === 'submit') {
|
||||
$context['private_offer_preparation'] = [...$context['private_offer_preparation'], 'submitted_by' => $actor->id,
|
||||
'submitted_at' => $decision['at'], 'submitted_revision' => $snapshot['record_version']];
|
||||
}
|
||||
}
|
||||
if (! $offer && ($record->status !== 'published' || in_array($action, ['suspend', 'archive'], true))) {
|
||||
$record->status = $next;
|
||||
}
|
||||
$record->management_version++;
|
||||
@@ -484,7 +572,12 @@ class OpportunityProfileWorkflow
|
||||
$record->save();
|
||||
app(PortalEditorialWorkflow::class)->notifyOpportunity($actor, $record, $action);
|
||||
$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']]);
|
||||
'after' => ['status' => $next, 'publication_status' => $record->status, 'version' => $record->management_version], 'reason' => $data['reason'],
|
||||
...($offer ? ['decision' => $decision, 'submission_hash' => $context['submission']['hash'] ?? null,
|
||||
'snapshot' => Crypt::encryptString(json_encode($snapshot, JSON_THROW_ON_ERROR))] : [])]);
|
||||
if ($offer && $action === 'submit') {
|
||||
$this->access->audit($actor, 'private_offer_submitted', ['record_id' => $record->id, 'version' => $record->management_version, 'public' => false]);
|
||||
}
|
||||
|
||||
return $record;
|
||||
});
|
||||
@@ -520,7 +613,9 @@ class OpportunityProfileWorkflow
|
||||
|
||||
private function authorizeAction(User $actor, Opportunity $record, string $action): void
|
||||
{
|
||||
abort_if($record->isPrivateOfferPreparation(), 403, 'A oferta está em preparação privada; o circuito editorial não está disponível nesta fase.');
|
||||
if ($record->isPrivateOfferPreparation()) {
|
||||
abort_unless(in_array($action, $this->actions($actor, $record), true), 403, 'Operação editorial não autorizada para esta oferta privada.');
|
||||
}
|
||||
$this->access->authorize($actor, 'view', $record);
|
||||
$promoter = $this->access->promoterRecordScope($actor, $record)
|
||||
&& ($record->organization_id !== null || (int) $record->owner_user_id === $actor->id);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\NotifyPrivateOfferDecision;
|
||||
use App\Models\DevelopmentCorridor;
|
||||
use App\Models\EconomicCluster;
|
||||
use App\Models\InvestmentDossier;
|
||||
@@ -237,12 +238,50 @@ final class PortalEditorialWorkflow
|
||||
}
|
||||
$context = $record->workflow_context ?? [];
|
||||
$recipients = array_unique(array_filter([$record->owner_user_id, $context['assignment']['assignee_user_id'] ?? null, $context['publication_assignment']['assignee_user_id'] ?? null]));
|
||||
$profiles = app(PortalProfileAccess::class);
|
||||
if ($record->isPrivateOfferPreparation() && $action === 'assign_review' && isset($context['last_decision']['previous_assignee_user_id'])) {
|
||||
$recipients[] = $context['last_decision']['previous_assignee_user_id'];
|
||||
}
|
||||
if ($record->isPrivateOfferPreparation() && $action === 'submit') {
|
||||
$coordinators = DB::table('portal_role_assignments')->where('role', 'validator')->whereNull('revoked_at')
|
||||
->where('starts_at', '<=', now())->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
|
||||
->whereJsonContains('responsibilities', 'validation_coordinator')->pluck('user_id');
|
||||
foreach (User::whereIn('id', $coordinators)->get() as $candidate) {
|
||||
if ($profiles->scoped($candidate, 'validator', $record->organization_id, $record->opportunity_category)
|
||||
&& $profiles->operational($candidate, 'validate', $record->id) && ! $profiles->conflict($candidate, $record)) {
|
||||
$recipients[] = $candidate->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
$recipients = array_unique($recipients);
|
||||
foreach ($recipients as $recipient) {
|
||||
if ((int) $recipient === $actor->id) {
|
||||
continue;
|
||||
}
|
||||
DB::table('portal_editorial_notifications')->insertOrIgnore(['user_id' => $recipient, 'edition_id' => null, 'opportunity_id' => $record->id, 'opportunity_version' => $record->management_version,
|
||||
if ($record->isPrivateOfferPreparation()) {
|
||||
$candidate = User::query()->whereKey($recipient)->first();
|
||||
if (! $candidate || ! $profiles->canView($candidate, $record) || ! $this->canEnter($candidate)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$inserted = DB::table('portal_editorial_notifications')->insertOrIgnore(['user_id' => $recipient, 'edition_id' => null, 'opportunity_id' => $record->id, 'opportunity_version' => $record->management_version,
|
||||
'content_hash' => $context['submission']['hash'] ?? $context['approval']['hash'] ?? null, 'action' => $action, 'created_at' => now()]);
|
||||
if ($inserted && $record->isPrivateOfferPreparation()) {
|
||||
$notice = DB::table('portal_editorial_notifications')->where('user_id', $recipient)->where('opportunity_id', $record->id)
|
||||
->where('opportunity_version', $record->management_version)->where('action', $action)->first();
|
||||
if ($notice) {
|
||||
DB::afterCommit(function () use ($notice, $actor, $record): void {
|
||||
try {
|
||||
NotifyPrivateOfferDecision::dispatch($notice->id);
|
||||
} catch (\Throwable $error) {
|
||||
// A committed decision must not become an uncertain HTTP failure.
|
||||
app(PortalProfileAccess::class)->audit($actor, 'offer_notification_delivery_failed', [
|
||||
'record_id' => $record->id, 'notice_id' => $notice->id, 'error_type' => get_class($error),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +373,9 @@ final class PortalEditorialWorkflow
|
||||
}
|
||||
$edition = $notice->edition_id ? DB::table('portal_editorial_editions')->where('id', $notice->edition_id)->first() : null;
|
||||
$subjectHref = ($edition->subject_kind ?? null) ? '/management/editorial/subjects/'.$edition->subject_kind.'/'.$edition->subject_key.'?edition='.$edition->id : null;
|
||||
if ($notice->opportunity_id !== null && Opportunity::query()->whereKey($notice->opportunity_id)->first()?->isPrivateOfferPreparation()) {
|
||||
$subjectHref = '/minhas-oportunidades/ofertas/'.$notice->opportunity_id;
|
||||
}
|
||||
$result[] = ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at,
|
||||
'href' => $subjectHref ?? ($notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : (($edition->demographic_import_id ?? null) ? '/management/editorial/demography/'.$edition->demographic_import_id : (($edition->dossier_id ?? null) ? '/management/editorial/portfolio/'.$edition->dossier_id : '/management/editorial/resources/'.$edition->resource_id)).'?edition='.$notice->edition_id), 'reference' => $notice->opportunity_id !== null ? 'Revisão '.$notice->opportunity_version : 'Edição '.$edition->revision_no];
|
||||
if (count($result) === 50) {
|
||||
|
||||
@@ -241,7 +241,7 @@ class PortalProfileAccess
|
||||
|
||||
public function canEdit(User $actor, Opportunity $record, ?string $newType = null): bool
|
||||
{
|
||||
if ($record->isPrivateOfferPreparation() && data_get($record->workflow_context, 'private_offer_preparation.state') !== 'draft') {
|
||||
if ($record->isPrivateOfferPreparation() && ! in_array($this->state($record), ['draft', 'needs_information'], true)) {
|
||||
return false;
|
||||
}
|
||||
if ($record->owner_user_id === null) {
|
||||
@@ -267,6 +267,10 @@ class PortalProfileAccess
|
||||
|
||||
public function state(Opportunity $record): string
|
||||
{
|
||||
if ($record->isPrivateOfferPreparation()) {
|
||||
return data_get($record->workflow_context, 'private_offer_preparation.state', 'draft');
|
||||
}
|
||||
|
||||
return $record->workflow_context['working_state'] ?? $record->status;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/** Phase 1 adapter: the existing opportunity owns identity, versions and audit. */
|
||||
/** Private commercial adapter: the existing opportunity owns identity, workflow and audit. */
|
||||
final class PrivateOfferPreparation
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
@@ -58,17 +58,31 @@ final class PrivateOfferPreparation
|
||||
return $record;
|
||||
}
|
||||
|
||||
/** Register private submission only: no editorial task, approval or publication.
|
||||
/** Register submission through the existing editorial engine when available.
|
||||
* @param array<string,mixed> $input */
|
||||
public function submit(User $actor, int $id, array $input): Opportunity
|
||||
{
|
||||
$data = Validator::make($input, ['etag' => 'required|string|size:64', 'confirm' => 'required|accepted'])->validate();
|
||||
if (app(PortalEditorialWorkflow::class)->available()) {
|
||||
$record = $this->record($actor, $id);
|
||||
$access = app(PortalProfileAccess::class);
|
||||
if ($access->state($record) === 'submitted') {
|
||||
abort_unless($access->promoterRecordScope($actor, $record) && $access->canCreate($actor, $record->organization_id, $record->opportunity_category)
|
||||
&& $access->operational($actor, 'edit', $id), 403);
|
||||
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409);
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
return app(OpportunityProfileWorkflow::class)->transition($actor, $id, [...$data, 'action' => 'submit', 'reason' => 'Submissão privada da oferta conferida pelo promotor.']);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $data) {
|
||||
$record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
|
||||
abort_unless($record->isPrivateOfferPreparation(), 404);
|
||||
$access = app(PortalProfileAccess::class);
|
||||
$access->authorize($actor, 'view', $record);
|
||||
abort_if(($record->workflow_context['private_offer_preparation']['version'] ?? 1) >= 2, 503, 'O circuito editorial está indisponível. A versão e o histórico foram preservados; não é permitida a retoma pelo percurso anterior.');
|
||||
abort_unless($access->promoterRecordScope($actor, $record)
|
||||
&& $access->canCreate($actor, $record->organization_id, $record->opportunity_category)
|
||||
&& $access->operational($actor, 'edit', $record->id), 403);
|
||||
@@ -106,14 +120,93 @@ final class PrivateOfferPreparation
|
||||
});
|
||||
}
|
||||
|
||||
/** Adapter of the five common fields; never relaxes the full canonical contract.
|
||||
* @return array<string,mixed> */
|
||||
public function snapshot(Opportunity $record): array
|
||||
{
|
||||
$values = app(OpportunityManagement::class)->values($record);
|
||||
Validator::make($values, ['title' => 'required|string|max:150', 'description' => 'required|string|max:50000',
|
||||
'opportunity_category' => 'required', 'private_offer.price_mode' => 'required|in:amount,negotiable', 'private_offer.contact_id' => 'required|uuid'])->validate();
|
||||
$offer = $this->validate($values['private_offer'], true);
|
||||
if (! app(OpportunityContacts::class)->related($record, $offer['contact_id'])) {
|
||||
throw ValidationException::withMessages(['private_offer.contact_id' => 'O contacto já não está disponível neste âmbito.']);
|
||||
}
|
||||
$contact = DB::table('contacts')->where('id', $offer['contact_id'])->first();
|
||||
$path = app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true);
|
||||
if (! empty($values['primary_territory_id']) && $path === []) {
|
||||
throw ValidationException::withMessages(['province_id' => 'Reconcilie a localização antes da decisão.']);
|
||||
}
|
||||
if ($path !== []) {
|
||||
app(InvestmentGeography::class)->selection(array_combine(array_slice(array_keys(InvestmentGeography::FIELDS), 0, count($path)), array_column($path, 'id')), true);
|
||||
}
|
||||
$payload = ['title' => $values['title'], 'description' => $values['description'], 'opportunity_category' => $values['opportunity_category'],
|
||||
'owner_user_id' => $record->owner_user_id, 'organization_id' => $record->organization_id, 'private_offer' => $offer,
|
||||
'contact_fingerprint' => hash('sha256', json_encode($contact, JSON_THROW_ON_ERROR)), 'territory' => $path];
|
||||
$normalize = function (mixed $value) use (&$normalize): mixed {
|
||||
if (is_object($value)) {
|
||||
$value = get_object_vars($value);
|
||||
}
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (! array_is_list($value)) {
|
||||
ksort($value);
|
||||
}
|
||||
|
||||
return array_map($normalize, $value);
|
||||
};
|
||||
$payload = $normalize($payload);
|
||||
|
||||
return ['payload' => $payload, 'hash' => hash('sha256', json_encode(['contract' => 'private-offer-editorial-v2', 'payload' => $payload], JSON_THROW_ON_ERROR)),
|
||||
'record_version' => $record->management_version, 'contract_version' => 'private-offer-editorial-v2', 'resources' => ['documents' => [], 'images' => [], 'locations' => []]];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function presentation(User $actor, Opportunity $record): array
|
||||
{
|
||||
app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
|
||||
$values = app(OpportunityManagement::class)->values($record);
|
||||
$fresh = null;
|
||||
$contactId = $values['private_offer']['contact_id'] ?? null;
|
||||
$contactLabel = $contactId && app(OpportunityContacts::class)->related($record, $contactId)
|
||||
? DB::table('contacts')->where('id', $contactId)->value('full_name') : null;
|
||||
if (isset($record->workflow_context['approval'])) {
|
||||
try {
|
||||
$fresh = hash_equals($record->workflow_context['approval']['hash'], $this->snapshot($record)['hash']);
|
||||
} catch (ValidationException $error) {
|
||||
$fresh = false;
|
||||
}
|
||||
}
|
||||
$history = $record->workflow_context['events'] ?? [];
|
||||
$names = User::whereIn('id', array_filter([...array_column($history, 'actor_id'), ...array_column($history, 'assignee_user_id')]))->pluck('name', 'id');
|
||||
$history = array_map(fn ($event) => [...$event, 'actor_name' => $names[$event['actor_id']] ?? 'Interveniente indisponível',
|
||||
'assignee_name' => isset($event['assignee_user_id']) ? ($names[$event['assignee_user_id']] ?? 'Interveniente indisponível') : null], $history);
|
||||
$versions = [...($record->workflow_context['decision_history'] ?? []), ...($record->workflow_context['submission_history'] ?? [])];
|
||||
if (isset($record->workflow_context['submission'])) {
|
||||
$versions[] = ['submission' => $record->workflow_context['submission'], 'approval' => $record->workflow_context['approval'] ?? null,
|
||||
'rejection' => $record->workflow_context['rejection'] ?? null];
|
||||
}
|
||||
$uniqueVersions = [];
|
||||
foreach ($versions as $version) {
|
||||
if (! isset($version['submission']['payload']['private_offer'])) {
|
||||
continue;
|
||||
}
|
||||
$key = $version['submission']['record_version'].':'.$version['submission']['hash'].':'.($version['submission']['review_cycle'] ?? 0);
|
||||
$previous = $uniqueVersions[$key] ?? [];
|
||||
$uniqueVersions[$key] = ['submission' => $version['submission'], 'approval' => $version['approval'] ?? $previous['approval'] ?? null,
|
||||
'rejection' => $version['rejection'] ?? $previous['rejection'] ?? null];
|
||||
}
|
||||
$workflow = app(OpportunityProfileWorkflow::class);
|
||||
$actions = $workflow->actions($actor, $record);
|
||||
|
||||
return ['id' => $record->id, 'reference' => $record->reference, 'values' => app(OpportunityManagement::class)->values($record),
|
||||
'etag' => app(OpportunityManagement::class)->etag($record), 'state' => data_get($record->workflow_context, 'private_offer_preparation.state'),
|
||||
return ['id' => $record->id, 'reference' => $record->reference, 'values' => $values,
|
||||
'etag' => app(OpportunityManagement::class)->etag($record), 'state' => app(PortalProfileAccess::class)->state($record),
|
||||
'canEdit' => app(PortalProfileAccess::class)->canEdit($actor, $record), 'organization_id' => $record->organization_id,
|
||||
'territory' => app(InvestmentGeography::class)->path($record->primary_territory_id, true)];
|
||||
'actions' => $actions,
|
||||
'reviewers' => in_array('assign_review', $actions, true) ? $workflow->participants($actor, $record)['validators'] : [],
|
||||
'reauthenticationRequired' => app(OpportunityProfileWorkflow::class)->reauthenticationRequired($actor, $record),
|
||||
'history' => $history, 'submittedVersions' => array_values($uniqueVersions), 'correctionItems' => $record->workflow_context['correction_items'] ?? [],
|
||||
'approval' => $record->workflow_context['approval'] ?? null, 'approvalFresh' => $fresh, 'contactLabel' => $contactLabel, 'tasks' => app(OpportunityWorkflowTasks::class)->listing($record),
|
||||
'territory' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10152,4 +10152,15 @@ Verificação: 14 testes novos. Bateria de regressão de Oportunidades/perfis/ed
|
||||
|
||||
Limitações: conferência visual manual não efectuada. npm run check global acusa formatação preexistente em 61 ficheiros alheios ao delta; esses ficheiros não foram alterados e a falha não foi dispensada. A bateria canónica extensa foi interrompida, não contabilizada como aprovada e substituída pela verificação do cenário afectado. Uma prova antiga de divulgação exige outra base pelo seu próprio controlo de segurança; esse controlo foi preservado, sem tocar nessa base, e a compatibilidade do âmbito nativo foi testada na nova preparação sintética.
|
||||
|
||||
Saldo de RF-MN-003: Fase 1 de Ofertas implementada e verificada tecnicamente em QA, sem instalação. Não encerra o requisito integral nem Procuras. Fase 2 e seguintes permanecem por autorizar; NDA, comprovativos, fotografias, validade, moderação e publicação não são simulados nesta entrega. A autorização anterior de validador jurídico para legislação não é estendida a Ofertas. Instalação em homologação ou produção exige verificação e autorização expressa separada.
|
||||
Saldo de RF-MN-003: Fase 1 de Ofertas implementada e verificada tecnicamente em QA, sem instalação. Não encerra o requisito integral nem Procuras. Fase 2 e seguintes permanecem por autorizar; NDA, comprovativos, fotografias, validade, moderação e publicação não são simulados nesta entrega. A autorização anterior de validador jurídico para legislação não é estendida a Ofertas. Instalação em homologação ou produção exige verificação e autorização expressa separada.
|
||||
## 14/11/2026 — Ofertas Fase 2, circuito editorial privado em QA
|
||||
|
||||
Autorização expressa da Tempo, após encerramento técnico da F1 com ressalva visual. Implementação limitada ao plano 6e302db6, mantendo Oportunidades como cadastro principal. Dashboard existente → Ofertas/revisão privada → validador inicia ou coordenação atribui → pede correcção/rejeita com fundamento → promotor corrige/reenvia no mesmo identificador → aprovação interna independente. Preserva versões, rejeições, aprovação histórica e tarefas. Reatribuição guarda quem alterou, anterior e novo responsável, sem conceder papéis.
|
||||
|
||||
Snapshots/hash, revisão e ciclo vinculam cada decisão aos campos e referências conferidos. Mudança no contacto/localização bloqueia decisão ou assinala reconferência; não substitui a conclusão. Primeira revisão explícita de submissão F1 preserva autor/data/revisão e regista a vinculação actual sem inventar fingerprints históricos do CRM. Estados editoriais privados não tornam a oferta pública; status de publicação mantém-se privado, marca/exclusão pública e percursos alternativos bloqueados. Os percursos nativos conservam a versão pública anterior durante alterações pendentes. Desactivar o circuito não permite submeter v2 pelo fallback v1 nem apagar histórico.
|
||||
|
||||
Reutiliza perfis/MFA/independência, workflow, tarefas, auditoria e caixa de notificações. Email pelo transporte existente, depois do commit e com revalidação, activação/lista operacional nativas; em QA apenas memória, sem mensagens reais ou SMS. Configurações de entrega e designações operacionais não são inventadas. Sem migrações/tabelas novas, mudanças em mne, dados operacionais, menu principal ou instalação.
|
||||
|
||||
QA sintética offers_phase1_20261114_test:55441: regressão 35/499, final F2 11/160, complemento de contribuidores 1/14 e HTML final 1/10 aprovados; grupos sobrepostos, não somar. Pint, PHPStan, tipos, lint/formatação focados e build QA aprovados. Testa autoria/contribuidores/âmbito/MFA/revogação, snapshots, rejeição/correcção/reenvio, reatribuição, fila sem duplicação, rollback, notificações idempotentes, fonte alterada, recuperação e recusa de publicação. Conferência visual mantém a ressalva da ferramenta; formatação global continua nos 61 ficheiros preexistentes exteriores ao delta, sem dispensa. Entregabilidade real não demonstrada. Relatório: docs/investmoz/v2/auditoria-portal/ofertas-fase2-entrega-qa-20261114.md. Recibo: outputs/offers-private-phase2-20261114/qa-result.json.
|
||||
|
||||
Saldo RF-MN-003: F1/F2 implementadas e verificadas tecnicamente em QA, não instaladas; requisito integral/Procuras permanecem parciais. Fase 3 e instalação em homologação/produção dependem de autorização expressa própria. Não foram iniciados catálogo público, NDA, comprovativos, fotografias ou validade. Próximo passo é rever esta entrega e conferir visualmente quando o ambiente permitir, sem reabrir provas técnicas aceites.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,75 @@
|
||||
# Ofertas — Fase 2, circuito editorial privado em QA
|
||||
|
||||
14/11/2026. Autorização expressa da Tempo nesta sessão, limitada à Fase 2 do plano `6e302db6`. Base: Fase 1 `1f2990e4`, mantida no histórico Git. Referência: RF-MN-003 da matriz existente.
|
||||
|
||||
**Implementado e verificado tecnicamente em QA. Sem instalação em homologação ou produção. A aprovação é interna; a publicação de Ofertas permanece bloqueada. Fase 3 não iniciada.**
|
||||
|
||||
## Percurso que ficou utilizável
|
||||
|
||||
No Dashboard Colaborativo existente, a área de Oportunidades inclui a entrada «Ofertas — revisão privada». Abre a mesma lista privada da Fase 1, agora com filtros por estado. Não há outro dashboard, outra tabela de ofertas, outro login ou alteração ao menu principal. As contagens de Oportunidades continuam a contar cada registo uma vez; a entrada filtrada não acrescenta outro contador ao total.
|
||||
|
||||
O promotor cria/guarda/recupera/submete. Um validador autorizado inicia a revisão, pede correcções nos campos comuns ou rejeita com fundamento. O promotor prepara a revisão após rejeição ou aprovação, conserva a decisão anterior, corrige e reenvia no mesmo identificador. O validador independente aprova a nova versão em privado. A coordenação de validação existente pode atribuir/reassociar um revisor elegível, com fundamento, histórico do responsável anterior e do novo responsável; não são criadas designações ou atribuições por inferência.
|
||||
|
||||
Estados: rascunho → submetida → em revisão → correcções solicitadas/rejeitada/aprovada internamente. Correcção e reenvio abrem novo ciclo; não eliminam rejeições, aprovações, snapshots ou tarefas encerradas. Aprovação não publica nem certifica titularidade, licenciamento ou viabilidade do negócio.
|
||||
|
||||
## Integração e garantias
|
||||
|
||||
- O mesmo `OpportunityProfileWorkflow` executa as transições. O adaptador privado valida os campos comerciais comuns; não exige nem fabrica os perfis normativos completos e não enfraquece o contrato dos investimentos existentes.
|
||||
- Oportunidades continua a ser o cadastro principal. `management_draft`, versões, etag, workflow_context, tarefas CRM, auditoria e notificações existentes são reutilizados. Não há novas migrações, tabelas ou mudanças em `mne`.
|
||||
- O estado editorial da Oferta é projectado a partir de uma única origem privada e sincronizado com as filas. O estado de publicação conserva a barreira privada: `status=draft`, `published_at=null` e a marca de preparação permanece. Nenhuma operação F2 remove a exclusão do catálogo.
|
||||
- Cada submissão regista versão do contrato, snapshot, hash determinístico, versão do registo submetido e ciclo de revisão. Cada decisão conserva actor, instante UTC, fundamento, hash e revisão a que respeita. O snapshot da decisão também fica cifrado na auditoria existente.
|
||||
- Contacto e localização são referenciados pelos cadastros existentes. O hash inclui os dados efectivamente conferidos nas referências; uma mudança posterior impede a aprovação dessa submissão ou sinaliza reconferência se a aprovação já ocorreu. A leitura não substitui silenciosamente a decisão.
|
||||
- Ofertas antigas da Fase 1 só são vinculadas na primeira operação explícita de revisão. Conservam autor, data e revisão originais. A vinculação regista quem/quando estabeleceu o fingerprint: a Fase 1 não tinha esse fingerprint de referências, pelo que não se inventa um estado histórico do CRM na data original.
|
||||
- As permissões são verificadas no servidor, na transacção e nos destinos das notificações. Autor, contribuidores e representantes em conflito não aprovam; acumulação de papéis não remove os bloqueios. Revisão/decisões internas exigem MFA recente. O papel de auditor permanece sem escrita operacional.
|
||||
- Tentativas de publicação são recusadas na rota privada, nas rotas alternativas nativas e no workflow chamado directamente. Os bloqueios anteriores do modelo e da divulgação e a exclusão da pesquisa pública permanecem. Não se implementou um publicador para Ofertas.
|
||||
- A versão pública anterior dos percursos nativos mantém-se durante edições pendentes; o ramo nativo não foi convertido para a preparação comercial. A regressão dos registos publicados confirma a preservação dos valores públicos. Não existem Ofertas públicas aceites nesta fase para simular uma publicação nova.
|
||||
|
||||
## Notificações e recuperação
|
||||
|
||||
A caixa editorial existente recebe as ocorrências por oportunidade/versão/acção e destinatário. A restrição de unicidade existente evita ocorrências repetidas. Submissões notificam coordenação elegível já designada; sem designação, a fila fica por atribuir. Decisões notificam autor e responsáveis efectivos, e a reatribuição inclui o responsável anterior se continuar autorizado. As ligações abrem a Oferta privada correcta e revalidam o acesso.
|
||||
|
||||
O novo job é apenas o consumidor do transporte de email/fila já existente: usa a activação e a lista de destinatários operacionais actuais, consulta a notificação novamente, reconfirma acesso e não envia conteúdo da Oferta. É agendado após a confirmação da transacção. Falhas imediatas de despacho/entrega ficam auditadas sem converter a decisão já confirmada numa resposta incerta. O job usa as tentativas/backoff do padrão existente. Não existe transporte SMS novo, envio real em QA ou promessa de entrega exactamente uma vez.
|
||||
|
||||
Se o circuito editorial ficar indisponível, uma Oferta v2 não é submetida pelo fallback v1: responde com indisponibilidade e conserva versão e histórico. Ofertas genuinamente v1 continuam com a preparação anterior. Não se faz rollback destrutivo de dados para regressar ao código precedente.
|
||||
|
||||
## Ficheiros do delta
|
||||
|
||||
| Ficheiros | Finalidade |
|
||||
| --- | --- |
|
||||
| `app/Services/PrivateOfferPreparation.php` | Adaptador, snapshots, vinculação F1, apresentação e histórico privado |
|
||||
| `app/Services/OpportunityProfileWorkflow.php` | Revisão, atribuição, decisões por versão e ciclos no motor existente |
|
||||
| `app/Services/PortalProfileAccess.php` | Projecção do estado e edição nos estados permitidos |
|
||||
| `app/Services/OpportunityManagement.php` | Guardar a correcção privada sem perder categoria/âmbito/localização actuais |
|
||||
| `app/Services/PortalEditorialWorkflow.php` | Destinatários, unicidade, destino privado e agendamento de notificações |
|
||||
| `app/Jobs/NotifyPrivateOfferDecision.php` | Email pelo transporte existente, com revalidação de acesso |
|
||||
| `app/Http/Controllers/PrivateOfferPreparationController.php` | Filtros e endpoint privado de decisões |
|
||||
| `app/Http/Controllers/PortalEditorialWorkflowController.php` | Entrada de Ofertas na área existente de Oportunidades |
|
||||
| `routes/web.php` | Endpoint privado de revisão, sob autenticação/perfis/resposta privada |
|
||||
| `resources/js/components/offer-editorial-review.tsx` | Fundamento, campos a corrigir, responsável, decisões e histórico |
|
||||
| `resources/js/lib/offer-editorial.ts` | Tipos e rótulos partilhados, sem importar uma página como biblioteca |
|
||||
| `resources/js/pages/offers/preparation.tsx` e `preparation-index.tsx` | Revisão, recuperação e filtros na preparação existente |
|
||||
| `resources/js/pages/management/editorial-workflow.tsx` | Entrada integrada, sem duplicar contagens |
|
||||
| `tests/Feature/PrivateOfferEditorialTest.php` | 11 testes novos do circuito e dos bloqueios |
|
||||
| Matriz, manual, este relatório e recibo QA | Saldo e evidência da entrega, sem outro inventário funcional |
|
||||
|
||||
## Verificação
|
||||
|
||||
Base sintética isolada: `offers_phase1_20261114_test:55441`, com as guardas existentes. Não foram utilizados processos reais, contas operacionais ou notificações externas.
|
||||
|
||||
- Integração e regressão: **35 testes / 499 asserções aprovados**, incluindo Fase 1, permissões, gravação/publicação nativa e o cenário canónico/editorial afectado.
|
||||
- Passagem final F2: **11 testes / 160 asserções aprovados**. O teste de conflito de contribuidores foi ainda reforçado e verificado separadamente: **1 teste / 14 asserções aprovado**.
|
||||
- HTML completo das duas páginas privadas com o build final: **1 teste / 10 asserções aprovado**.
|
||||
- Pint, PHPStan, tipos, lint/formatação focados e compilação QA aprovados. A análise PHP foi repetida para o motor após o ajuste final do ramo privado. Sem enfraquecer testes, baseline ou guardas.
|
||||
- Os grupos sobrepõem-se; não somar os totais. Execuções interrompidas ou anteriores com falhas não são contadas como aprovadas.
|
||||
|
||||
Os testes cobrem fila existente sem contagem duplicada, rejeição/correcção/reenvio, aprovação vinculada ao snapshot, decisão desactualizada, âmbito/MFA/contribuidores/auto-aprovação, perda de acesso, contacto alterado, reatribuição restrita, rollback conjunto, notificação repetida, email no transporte local em memória, revogação antes da entrega, vinculação explícita F1, indisponibilidade sem downgrade e recusa de publicação por percursos alternativos. O histórico recuperado conserva o título/preço e a decisão de cada versão.
|
||||
|
||||
## Riscos e pendências
|
||||
|
||||
1. Conferência visual manual continua pendente: a última prova da ferramenta registou host de navegador indisponível e falha de captura nativa GPU `0x887A0005`. Não foram repetidos reinícios ou desactivadas verificações. HTML e compilação não são apresentados como aceite visual.
|
||||
2. A verificação global de formatação continua a falhar nos 61 ficheiros preexistentes. A lista foi novamente confrontada com este delta, sem intersecção. Não foi reformatado conteúdo exterior ao âmbito nem dispensada a falha global.
|
||||
3. Email real, configuração operacional da fila, destinatários e entregabilidade não foram activados nem demonstrados. Os testes utilizam transporte em memória e os papéis sintéticos existentes no cenário.
|
||||
4. Atribuição/reassociação exige coordenação já designada e candidatos elegíveis. A inexistência de uma designação não autoriza criar papéis; bloqueia apenas essa atribuição e mantém a fila por tratar.
|
||||
5. O recorte aprovado é moderação dos campos comerciais comuns. NDA, comprovativos, fotografias, validade, catálogo público, destaque e restantes fases não são declarados concluídos. Não se atribui a Ofertas a designação jurídica da legislação.
|
||||
|
||||
**Saldo:** Fase 2 implementada e verificada tecnicamente em QA; não instalada. O módulo integral/RF-MN-003 e Procuras continuam parciais. A Fase 3 e qualquer instalação exigem autorização expressa separada. Próximo passo: revisão desta entrega e conferência visual quando o ambiente permitir, sem reabrir as provas técnicas aceites.
|
||||
@@ -0,0 +1,212 @@
|
||||
{
|
||||
"date": "2026-11-14",
|
||||
"scope": "Ofertas - Fase 2 - Circuito Editorial privado",
|
||||
"base_revision": "6e302db62e7c3b9f62ac417b8591dae4ff7e0b2c",
|
||||
"phase1_revision": "1f2990e4",
|
||||
"approved_plan_revision": "6e302db6",
|
||||
"state": "implemented_and_technically_verified_in_qa_not_installed",
|
||||
"qa_database": "offers_phase1_20261114_test:55441",
|
||||
"new_tests": 11,
|
||||
"results": {
|
||||
"tests-final.log": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 35,
|
||||
"passed": 35,
|
||||
"assertions": 499,
|
||||
"duration_ms": 207928
|
||||
},
|
||||
"tests-focused-final.log": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 11,
|
||||
"passed": 11,
|
||||
"assertions": 160,
|
||||
"duration_ms": 119998
|
||||
},
|
||||
"contributor-final.log": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 1,
|
||||
"passed": 1,
|
||||
"assertions": 14,
|
||||
"duration_ms": 15395
|
||||
},
|
||||
"html-final.log": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 1,
|
||||
"passed": 1,
|
||||
"assertions": 10,
|
||||
"duration_ms": 9396
|
||||
}
|
||||
},
|
||||
"runs_overlap": true,
|
||||
"checks": {
|
||||
"pint": "passed",
|
||||
"phpstan_full": "passed",
|
||||
"phpstan_final_workflow": "passed",
|
||||
"typescript": "passed",
|
||||
"focused_frontend_lint_format": "passed",
|
||||
"qa_build": "passed",
|
||||
"real_compiled_html": "passed",
|
||||
"git_diff_check": "passed",
|
||||
"global_npm_check": "failed: 61 unchanged pre-existing formatting files outside the delta; not waived"
|
||||
},
|
||||
"global_format_files": [
|
||||
"resources/js/components/funding-classification.tsx",
|
||||
"resources/js/components/institutional-source/source-text.tsx",
|
||||
"resources/js/components/password-input.tsx",
|
||||
"resources/js/components/sector-entry.tsx",
|
||||
"resources/js/components/sector-resource-reference.tsx",
|
||||
"resources/js/components/simulation-form.tsx",
|
||||
"resources/js/components/simulation-irpc-annual.tsx",
|
||||
"resources/js/components/structure-geography.tsx",
|
||||
"resources/js/components/tempo-header-tools.tsx",
|
||||
"resources/js/lib/investor-path-stage.ts",
|
||||
"resources/js/lib/irpc-annual-settlement.ts",
|
||||
"resources/js/pages/funding/assisted.tsx",
|
||||
"resources/js/pages/funding/manage.tsx",
|
||||
"resources/js/pages/funding/saved.tsx",
|
||||
"resources/js/pages/investment/catalogue.tsx",
|
||||
"resources/js/pages/investment/detail.tsx",
|
||||
"resources/js/pages/investor-path/index.tsx",
|
||||
"resources/js/pages/investor-path/preparation.tsx",
|
||||
"resources/js/pages/investor-path/show.tsx",
|
||||
"resources/js/pages/management/demography-install.tsx",
|
||||
"resources/js/pages/portal/fiscal-forms.tsx",
|
||||
"resources/js/pages/portal/search.tsx",
|
||||
"resources/js/translation/page-translator.tsx",
|
||||
"resources/js/translation/service.ts",
|
||||
"resources/js/translation/types.ts",
|
||||
"resources/js/translation/utils.ts",
|
||||
"tests/frontend/cluster-editorial.test.mjs",
|
||||
"tests/frontend/dashboard-navigation-sections.test.mjs",
|
||||
"tests/frontend/editorial-completion.test.mjs",
|
||||
"tests/frontend/editorial-dashboard-manifest.test.mjs",
|
||||
"tests/frontend/editorial-dashboard-presentation.test.mjs",
|
||||
"tests/frontend/editorial-edition.test.mjs",
|
||||
"tests/frontend/education-review.test.mjs",
|
||||
"tests/frontend/fiscal-form-entry.test.mjs",
|
||||
"tests/frontend/funding-directory.test.mjs",
|
||||
"tests/frontend/global-search.test.mjs",
|
||||
"tests/frontend/helpers/irpc-advance-period-case.mjs",
|
||||
"tests/frontend/helpers/irpc-annual-noncivil-case.mjs",
|
||||
"tests/frontend/helpers/irpc-investment-combination-case.mjs",
|
||||
"tests/frontend/investment-map-indicators.test.mjs",
|
||||
"tests/frontend/investment-preparation.test.mjs",
|
||||
"tests/frontend/irpc-advance-cashflow-ui.test.mjs",
|
||||
"tests/frontend/irpc-advance-cashflow.test.mjs",
|
||||
"tests/frontend/irpc-advance-period-ui.test.mjs",
|
||||
"tests/frontend/irpc-advance-period.test.mjs",
|
||||
"tests/frontend/irpc-annual-noncivil-ui.test.mjs",
|
||||
"tests/frontend/irpc-annual-noncivil.test.mjs",
|
||||
"tests/frontend/irpc-investment-carry-combination.test.mjs",
|
||||
"tests/frontend/irpc-pec-correction.test.mjs",
|
||||
"tests/frontend/irpc-pec-last-amount.test.mjs",
|
||||
"tests/frontend/irpc-pec-obligation-ui.test.mjs",
|
||||
"tests/frontend/irpc-pec-obligation.test.mjs",
|
||||
"tests/frontend/irpc-ppc-obligation-ui.test.mjs",
|
||||
"tests/frontend/licensing-stage.test.mjs",
|
||||
"tests/frontend/navigation-sidebar-render.test.mjs",
|
||||
"tests/frontend/opportunity-curation.test.mjs",
|
||||
"tests/frontend/opportunity-information.test.mjs",
|
||||
"tests/frontend/portal-institutional-contact.test.mjs",
|
||||
"tests/frontend/portal-navigation.test.mjs",
|
||||
"tests/frontend/portal-six-group-menu.test.mjs",
|
||||
"tests/frontend/sector-resource-reference.test.mjs"
|
||||
],
|
||||
"format_intersection_with_delta": [],
|
||||
"capabilities": [
|
||||
"Existing dashboard and opportunity workflow",
|
||||
"Scoped review and coordinator reassignment",
|
||||
"Reasoned rejection and correction/resubmission",
|
||||
"Exact snapshot/hash/version/cycle decisions",
|
||||
"Private approval with all publication barriers preserved",
|
||||
"Audit and immutable submission history",
|
||||
"Scoped inbox and existing mail transport after commit",
|
||||
"Rollback and no downgrade to phase one"
|
||||
],
|
||||
"new_migrations": [],
|
||||
"new_tables": [],
|
||||
"production_data_modified": false,
|
||||
"mne_modified": false,
|
||||
"main_menu_modified": false,
|
||||
"homologation_installed": false,
|
||||
"production_installed": false,
|
||||
"phase3_started": false,
|
||||
"real_messages_sent": false,
|
||||
"limits": [
|
||||
"Visual review pending: previously recorded browser-host and native GPU capture failures; no restart or security changes.",
|
||||
"Global formatting failure remains.",
|
||||
"Real email delivery and operational queue configuration not activated or tested.",
|
||||
"Initial phase-one reference fingerprints were not recorded; explicit review binding records current fingerprint without backdating.",
|
||||
"No exactly-once guarantee for an external email provider.",
|
||||
"Reassignment requires an existing scoped validation coordinator; no permissions created."
|
||||
],
|
||||
"files": [
|
||||
"app/Http/Controllers/PortalEditorialWorkflowController.php",
|
||||
"app/Http/Controllers/PrivateOfferPreparationController.php",
|
||||
"app/Services/OpportunityManagement.php",
|
||||
"app/Services/OpportunityProfileWorkflow.php",
|
||||
"app/Services/PortalEditorialWorkflow.php",
|
||||
"app/Services/PortalProfileAccess.php",
|
||||
"app/Services/PrivateOfferPreparation.php",
|
||||
"docs/investmoz/unification/traceability-and-manual.md",
|
||||
"docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv",
|
||||
"resources/js/pages/management/editorial-workflow.tsx",
|
||||
"resources/js/pages/offers/preparation-index.tsx",
|
||||
"resources/js/pages/offers/preparation.tsx",
|
||||
"routes/web.php",
|
||||
"app/Jobs/NotifyPrivateOfferDecision.php",
|
||||
"docs/investmoz/v2/auditoria-portal/ofertas-fase2-entrega-qa-20261114.md",
|
||||
"resources/js/components/offer-editorial-review.tsx",
|
||||
"resources/js/lib/offer-editorial.ts",
|
||||
"tests/Feature/PrivateOfferEditorialTest.php"
|
||||
],
|
||||
"source_sha256": {
|
||||
"app/Http/Controllers/PortalEditorialWorkflowController.php": "9cb4be9a15f8d332376006b5bb9f6160461391e4ab28bce3b6f8512bb606e91c",
|
||||
"app/Http/Controllers/PrivateOfferPreparationController.php": "d9d3cbb6e5b8ca42f508f836029021c5cc2feda38661a40e446b2057cfa36cc4",
|
||||
"app/Services/OpportunityManagement.php": "4a837c99e7f029fbaa5e0421c5321dc8c113c70c9afc0c012497b2f313519864",
|
||||
"app/Services/OpportunityProfileWorkflow.php": "89253fce194476516d3604e110f6315cf32e18c8cc1b1a1aaf00987a51c08995",
|
||||
"app/Services/PortalEditorialWorkflow.php": "9d3a560ab01cb04a84386f1e7f28564c67199ed2bf2bb5ae518e2b37d854baf6",
|
||||
"app/Services/PortalProfileAccess.php": "835eb53972391f4b790a2b0773b771bc0206f52dbf62952521885400dc2d44b7",
|
||||
"app/Services/PrivateOfferPreparation.php": "ce6756986351a469286377e27af14e55a93fe9981e705625ab8fe646deff6f20",
|
||||
"docs/investmoz/unification/traceability-and-manual.md": "3ddb72a1bad22bf776f4540d4d47d08e270524e248b293d800e3726679805d9e",
|
||||
"docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv": "ffcb117e0b3d3ff54fa0ba77787c64989dcc839590726b4f901ae04429d6ae7a",
|
||||
"resources/js/pages/management/editorial-workflow.tsx": "8d21d2f5d3453c26945a829423b1369d58999ee35714ca90c337f155a23beee0",
|
||||
"resources/js/pages/offers/preparation-index.tsx": "6f2ca4f41e2b6d9579af353a4d96dc4f001be187c628ef611a605966be2c98d5",
|
||||
"resources/js/pages/offers/preparation.tsx": "99abe86f7233741eff43836f3c07fca333212c81dc1f4a14cd8f2e3f7609691a",
|
||||
"routes/web.php": "ece5b61bafff593da234838abdfddda08be161e111bad4331dd95d5c28aad53d",
|
||||
"app/Jobs/NotifyPrivateOfferDecision.php": "15aa3ac867e9f6ca2569eff0ad3fc483dc41b3ca200efefae167d179c24888b5",
|
||||
"docs/investmoz/v2/auditoria-portal/ofertas-fase2-entrega-qa-20261114.md": "a8d95ca260ac6be6e60284bf99584cbf72df9b69eed4ffa8233b3c7bc956e5cd",
|
||||
"resources/js/components/offer-editorial-review.tsx": "c519fb77848d8760ea88556184201fbaef27bbf7f9b00adefcff04de191ba81e",
|
||||
"resources/js/lib/offer-editorial.ts": "dc70169aaa746b9ae8063b0312a6601d0347d2927a7c0f4bfc58cfc1f31aae99",
|
||||
"tests/Feature/PrivateOfferEditorialTest.php": "1fed1ca2c5241e886f76d37f7d5ac4410952eb4361fbfe03b4f76c009abd4b6d"
|
||||
},
|
||||
"report": "docs/investmoz/v2/auditoria-portal/ofertas-fase2-entrega-qa-20261114.md",
|
||||
"matrix": {
|
||||
"id": "RF-MN-003",
|
||||
"rows_changed": 1,
|
||||
"columns_preserved": 49
|
||||
},
|
||||
"test_commands": [
|
||||
"php vendor/bin/phpunit --filter 'PrivateOfferEditorialTest|PrivateOfferPreparationTest|OpportunityManagementTest|PortalProfileAccessTest|test_phase_one_requires_a_new_legal_decision_for_each_content_and_attachment_version|test_notice_receipt_never_grants_access_and_revocation_blocks_publication' tests/Feature/PrivateOfferEditorialTest.php tests/Feature/PrivateOfferPreparationTest.php tests/Feature/OpportunityManagementTest.php tests/Feature/PortalProfileAccessTest.php tests/Feature/OpportunityCanonicalWorkflowTest.php tests/Feature/PortalEditorialWorkflowTest.php",
|
||||
"php vendor/bin/phpunit tests/Feature/PrivateOfferEditorialTest.php",
|
||||
"php vendor/bin/phpunit --filter test_author_contributor_wrong_scope_auditor_and_unassigned_reviewer_cannot_approve tests/Feature/PrivateOfferEditorialTest.php",
|
||||
"php vendor/bin/phpunit --filter test_full_html_uses_real_compiled_private_page_entries tests/Feature/PrivateOfferPreparationTest.php"
|
||||
],
|
||||
"test_environment": {
|
||||
"APP_ENV": "testing",
|
||||
"DB_HOST": "127.0.0.1",
|
||||
"DB_PORT": "55441",
|
||||
"DB_DATABASE": "offers_phase1_20261114_test",
|
||||
"DB_USERNAME": "demography_qa",
|
||||
"EDITORIAL_QA_BUILD_DIRECTORY": "build-qa-opportunities",
|
||||
"synthetic_app_key": true,
|
||||
"mail": "array",
|
||||
"queue": "sync"
|
||||
},
|
||||
"build_command": "INVESTMOZ_QA_BUILD=1 npm.cmd run build",
|
||||
"failed_or_interrupted_development_runs_not_counted": true
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useState } from 'react';
|
||||
import { router } from '@inertiajs/react';
|
||||
import {
|
||||
offerStates,
|
||||
offerActions,
|
||||
type OfferEditorialRecord,
|
||||
} from '@/lib/offer-editorial';
|
||||
import { profileJson } from '@/lib/profile-json';
|
||||
|
||||
const fields: Record<string, string> = {
|
||||
title: 'Título',
|
||||
description: 'Descrição',
|
||||
opportunity_category: 'Categoria',
|
||||
private_offer: 'Preço',
|
||||
contact: 'Contacto',
|
||||
location: 'Localização',
|
||||
};
|
||||
|
||||
export default function OfferEditorialReview<T extends OfferEditorialRecord>({
|
||||
record,
|
||||
onChanged,
|
||||
}: {
|
||||
record: T;
|
||||
onChanged: (record: T) => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [corrections, setCorrections] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [assignee, setAssignee] = useState('');
|
||||
const actions = record.actions.filter((action) => action !== 'submit');
|
||||
async function decide(action: string) {
|
||||
if (busy || record.reauthenticationRequired) return;
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await profileJson(
|
||||
`/minhas-oportunidades/ofertas/${record.id}/revisao`,
|
||||
{
|
||||
etag: record.etag,
|
||||
action,
|
||||
reason,
|
||||
...(action === 'assign_review'
|
||||
? { assignee_user_id: Number(assignee) }
|
||||
: {}),
|
||||
...(action === 'request_correction'
|
||||
? { correction_items: corrections }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
onChanged(result.record);
|
||||
setReason('');
|
||||
setCorrections([]);
|
||||
setMessage(result.message);
|
||||
if (action === 'request_revision')
|
||||
router.visit(`/minhas-oportunidades/ofertas/${record.id}`, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Não foi possível registar a decisão. O fundamento foi preservado.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section
|
||||
className="space-y-4 rounded-lg border p-4 md:p-6"
|
||||
aria-label="Revisão editorial privada"
|
||||
>
|
||||
<h2 className="font-semibold">Revisão da oferta</h2>
|
||||
<p>
|
||||
{offerStates[record.state] ?? record.state}. A publicação
|
||||
pública permanece indisponível nesta fase.
|
||||
</p>
|
||||
{record.approvalFresh === false && (
|
||||
<p role="alert">
|
||||
As referências mudaram desde a aprovação. Prepare nova
|
||||
revisão; a decisão anterior fica no histórico.
|
||||
</p>
|
||||
)}
|
||||
{record.correctionItems.length > 0 &&
|
||||
record.state === 'needs_information' && (
|
||||
<p>
|
||||
Campos a rever:{' '}
|
||||
{record.correctionItems
|
||||
.map((field) => fields[field] ?? field)
|
||||
.join(', ')}
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
{record.reauthenticationRequired && (
|
||||
<p role="alert">
|
||||
Renove o MFA pela autenticação habitual antes de registar
|
||||
uma decisão. Os dados guardados permanecem disponíveis.
|
||||
</p>
|
||||
)}
|
||||
{actions.length > 0 && (
|
||||
<fieldset
|
||||
className="space-y-3"
|
||||
disabled={busy || record.reauthenticationRequired}
|
||||
>
|
||||
<label className="block">
|
||||
Fundamento da operação
|
||||
<textarea
|
||||
className="bg-background w-full rounded-md border p-2"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
minLength={20}
|
||||
maxLength={1000}
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
{actions.includes('assign_review') && (
|
||||
<label className="block">
|
||||
Responsável pela revisão
|
||||
<select
|
||||
className="bg-background w-full rounded-md border p-2"
|
||||
value={assignee}
|
||||
onChange={(event) =>
|
||||
setAssignee(event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccione um validador autorizado
|
||||
</option>
|
||||
{record.reviewers.map((reviewer) => (
|
||||
<option
|
||||
key={reviewer.id}
|
||||
value={reviewer.id}
|
||||
>
|
||||
{reviewer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{actions.includes('request_correction') && (
|
||||
<fieldset>
|
||||
<legend>Campos a corrigir</legend>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{Object.entries(fields).map(
|
||||
([field, label]) => (
|
||||
<label
|
||||
key={field}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={corrections.includes(
|
||||
field,
|
||||
)}
|
||||
onChange={(event) =>
|
||||
setCorrections(
|
||||
(previous) =>
|
||||
event.target.checked
|
||||
? [
|
||||
...previous,
|
||||
field,
|
||||
]
|
||||
: previous.filter(
|
||||
(value) =>
|
||||
value !==
|
||||
field,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
type="button"
|
||||
key={action}
|
||||
className="bg-primary text-primary-foreground rounded-md px-4 py-2 disabled:opacity-50"
|
||||
disabled={
|
||||
busy ||
|
||||
reason.trim().length < 20 ||
|
||||
(action === 'request_correction' &&
|
||||
!corrections.length) ||
|
||||
(action === 'assign_review' && !assignee)
|
||||
}
|
||||
onClick={() => void decide(action)}
|
||||
>
|
||||
{offerActions[action] ?? action}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
<p role="status" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
{record.submittedVersions.length > 0 && (
|
||||
<details>
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Versões submetidas
|
||||
</summary>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{record.submittedVersions.map((version, index) => (
|
||||
<li key={index} className="rounded border p-3">
|
||||
<p>
|
||||
Revisão {version.submission.record_version}:{' '}
|
||||
{version.submission.payload.title}
|
||||
</p>
|
||||
<p>
|
||||
{version.submission.payload.private_offer
|
||||
.price_mode === 'negotiable'
|
||||
? 'A negociar'
|
||||
: `${version.submission.payload.private_offer.amount} ${version.submission.payload.private_offer.currency}`}
|
||||
</p>
|
||||
{version.approval && (
|
||||
<p>Aprovação: {version.approval.reason}</p>
|
||||
)}
|
||||
{version.rejection && (
|
||||
<p>Rejeição: {version.rejection.reason}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
{record.history.length > 0 && (
|
||||
<details>
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Histórico de revisão
|
||||
</summary>
|
||||
<ol className="mt-3 space-y-3">
|
||||
{record.history.map((event, index) => (
|
||||
<li key={index} className="rounded border p-3">
|
||||
<p className="font-medium">
|
||||
{offerActions[event.action] ?? event.action}{' '}
|
||||
· {event.actor_name}
|
||||
</p>
|
||||
<p>{event.reason}</p>
|
||||
{event.assignee_name && (
|
||||
<p>Responsável: {event.assignee_name}</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{event.at} · Revisão submetida{' '}
|
||||
{event.submitted_revision}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export const offerStates: Record<string, string> = {
|
||||
draft: 'Rascunho privado',
|
||||
submitted: 'Submetida para revisão',
|
||||
in_review: 'Em revisão',
|
||||
needs_information: 'Correcções solicitadas',
|
||||
rejected: 'Rejeitada',
|
||||
approved: 'Aprovada internamente',
|
||||
};
|
||||
export const offerActions: Record<string, string> = {
|
||||
assign_review: 'Atribuir revisão',
|
||||
submit: 'Submeter',
|
||||
start_review: 'Iniciar revisão',
|
||||
request_correction: 'Solicitar correcção',
|
||||
reject: 'Rejeitar com fundamento',
|
||||
approve: 'Aprovar internamente',
|
||||
request_revision: 'Preparar correcção e reenvio',
|
||||
};
|
||||
export type OfferEditorialRecord = {
|
||||
reviewers: { id: number; name: string }[];
|
||||
id: number;
|
||||
etag: string;
|
||||
state: string;
|
||||
actions: string[];
|
||||
reauthenticationRequired: boolean;
|
||||
approvalFresh: boolean | null;
|
||||
correctionItems: string[];
|
||||
history: {
|
||||
action: string;
|
||||
actor_name: string;
|
||||
assignee_name?: string | null;
|
||||
reason: string;
|
||||
at: string;
|
||||
opportunity_version?: number;
|
||||
submitted_revision?: number;
|
||||
}[];
|
||||
submittedVersions: {
|
||||
submission: {
|
||||
record_version: number;
|
||||
at?: string;
|
||||
payload: {
|
||||
title: string;
|
||||
private_offer: {
|
||||
price_mode: string;
|
||||
amount?: string;
|
||||
currency?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
approval?: { reason: string; hash: string } | null;
|
||||
rejection?: { reason: string; hash: string } | null;
|
||||
}[];
|
||||
};
|
||||
@@ -40,6 +40,7 @@ export default function EditorialWorkflow({
|
||||
filters = {},
|
||||
resourceAccess,
|
||||
opportunitiesUrl,
|
||||
offersUrl = null,
|
||||
opportunityPending,
|
||||
clustersUrl,
|
||||
portfolioUrl,
|
||||
@@ -54,6 +55,7 @@ export default function EditorialWorkflow({
|
||||
}: {
|
||||
resourceAccess: boolean;
|
||||
opportunitiesUrl: string | null;
|
||||
offersUrl?: string | null;
|
||||
opportunityPending: number;
|
||||
clustersUrl?: string | null;
|
||||
portfolioUrl?: string | null;
|
||||
@@ -104,6 +106,10 @@ export default function EditorialWorkflow({
|
||||
href: opportunitiesUrl,
|
||||
pending: opportunityPending,
|
||||
},
|
||||
{
|
||||
label: 'Ofertas — revisão privada',
|
||||
href: offersUrl,
|
||||
},
|
||||
{
|
||||
label: 'Biblioteca e editores',
|
||||
href: resourceAccess ? '/management/portal-services' : null,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { offerStates } from '@/lib/offer-editorial';
|
||||
|
||||
export default function OfferPreparations({
|
||||
records,
|
||||
canCreate,
|
||||
filters = {},
|
||||
}: {
|
||||
records: {
|
||||
data: {
|
||||
@@ -15,6 +17,7 @@ export default function OfferPreparations({
|
||||
next_page_url: string | null;
|
||||
};
|
||||
canCreate: boolean;
|
||||
filters?: { state?: string };
|
||||
}) {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl space-y-6 p-4 md:p-8">
|
||||
@@ -37,6 +40,28 @@ export default function OfferPreparations({
|
||||
</Link>
|
||||
)}
|
||||
</header>
|
||||
<nav
|
||||
aria-label="Filtrar ofertas por estado"
|
||||
className="flex flex-wrap gap-3 text-sm"
|
||||
>
|
||||
<Link
|
||||
href="/minhas-oportunidades/ofertas"
|
||||
aria-current={!filters.state ? 'page' : undefined}
|
||||
>
|
||||
Todas
|
||||
</Link>
|
||||
{Object.entries(offerStates).map(([state, label]) => (
|
||||
<Link
|
||||
key={state}
|
||||
href={`/minhas-oportunidades/ofertas?state=${state}`}
|
||||
aria-current={
|
||||
filters.state === state ? 'page' : undefined
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
{!records.data.length && (
|
||||
<p>Ainda não existem ofertas neste âmbito.</p>
|
||||
)}
|
||||
@@ -51,9 +76,7 @@ export default function OfferPreparations({
|
||||
</Link>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{record.reference} ·{' '}
|
||||
{record.state === 'submitted'
|
||||
? 'Submissão privada registada'
|
||||
: 'Rascunho privado'}
|
||||
{offerStates[record.state] ?? record.state}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -8,6 +8,8 @@ import OpportunityContactForm from '@/components/opportunity-contact-form';
|
||||
import { modalityLabels } from '@/components/opportunity-search-filters';
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
|
||||
import OfferEditorialReview from '@/components/offer-editorial-review';
|
||||
import { type OfferEditorialRecord } from '@/lib/offer-editorial';
|
||||
|
||||
type Offer = {
|
||||
price_mode?: string | null;
|
||||
@@ -20,7 +22,7 @@ type Context = {
|
||||
organization_name: string | null;
|
||||
type: string;
|
||||
};
|
||||
type RecordData = {
|
||||
type RecordData = OfferEditorialRecord & {
|
||||
id: number;
|
||||
reference: string;
|
||||
etag: string;
|
||||
@@ -28,6 +30,7 @@ type RecordData = {
|
||||
canEdit: boolean;
|
||||
organization_id: string | null;
|
||||
territory: TerritoryOption[];
|
||||
contactLabel?: string | null;
|
||||
values: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
@@ -76,7 +79,19 @@ export default function OfferPreparation({
|
||||
},
|
||||
);
|
||||
const [territory, setTerritory] = useState(record?.territory ?? emptyPath);
|
||||
const [contactChoices, setContactChoices] = useState(contacts);
|
||||
const [contactChoices, setContactChoices] = useState(() => {
|
||||
const selected = record?.values.private_offer?.contact_id;
|
||||
return selected && !contacts.some((contact) => contact.id === selected)
|
||||
? [
|
||||
...contacts,
|
||||
{
|
||||
id: selected,
|
||||
full_name:
|
||||
record?.contactLabel ?? 'Contacto indisponível',
|
||||
},
|
||||
]
|
||||
: contacts;
|
||||
});
|
||||
const [dirty, setDirty] = useState(!record);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
@@ -159,6 +174,16 @@ export default function OfferPreparation({
|
||||
</p>
|
||||
{saved && <p className="mt-2 text-sm">{saved.reference}</p>}
|
||||
</header>
|
||||
{saved && (
|
||||
<OfferEditorialReview
|
||||
record={saved}
|
||||
onChanged={(next) => {
|
||||
setSaved(next);
|
||||
setDirty(false);
|
||||
setConfirmed(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div role="status" aria-live="polite">
|
||||
{message}
|
||||
</div>
|
||||
|
||||
@@ -239,6 +239,7 @@ Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(func
|
||||
Route::get('/{id}', [$offers, 'edit'])->whereNumber('id');
|
||||
Route::post('/{id}/guardar', [$offers, 'save'])->whereNumber('id');
|
||||
Route::post('/{id}/submeter', [$offers, 'submit'])->whereNumber('id');
|
||||
Route::post('/{id}/revisao', [$offers, 'transition'])->whereNumber('id');
|
||||
});
|
||||
Route::get('/minhas-oportunidades/oferta-interna', [ProactiveSupplyController::class, 'index'])->middleware('throttle:60,1');
|
||||
Route::get('/minhas-oportunidades/oferta-interna/revisao', [ProactiveSupplyController::class, 'review'])->middleware(['throttle:60,1', PrivateInvestmentResponse::class]);
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\NotifyPrivateOfferDecision;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityProfileWorkflow;
|
||||
use App\Services\PortalEditorialWorkflow;
|
||||
use App\Services\PortalProfileAdministration;
|
||||
use App\Services\PrivateOfferPreparation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Mail\MailManager;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class PrivateOfferEditorialTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const BASE = '/minhas-oportunidades/ofertas';
|
||||
|
||||
private const REASON = 'Decisão fundamentada exclusivamente com dados sintéticos QA.';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
Mail::fake();
|
||||
config(['portal.profile_access_enabled' => true, 'editorial_workflow.enabled' => true]);
|
||||
}
|
||||
|
||||
private function actor(string $role, array $responsibilities = [], array $types = ['business_sale']): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC-QA'), 'two_factor_confirmed_at' => now()])->save();
|
||||
$this->grant($user, $role, $responsibilities, $types);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function grant(User $user, string $role, array $responsibilities = [], array $types = ['business_sale']): void
|
||||
{
|
||||
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [
|
||||
'user_id' => $user->id, 'role' => $role, 'organization_id' => null, 'responsibilities' => $responsibilities,
|
||||
'opportunity_types' => $types, 'source' => 'synthetic-test-fixture',
|
||||
]);
|
||||
}
|
||||
|
||||
private function login(User $actor): static
|
||||
{
|
||||
return $this->actingAs($actor)->withSession(['login.id' => null, 'portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->fresh()->portal_access_revision]);
|
||||
}
|
||||
|
||||
private function draft(User $owner): array
|
||||
{
|
||||
$contact = (string) Str::uuid();
|
||||
DB::table('contacts')->insert(['id' => $contact, 'full_name' => 'Contacto sintético QA', 'email' => 'synthetic-'.Str::uuid().'@example.test',
|
||||
'source_system' => 'portal-user-contact', 'external_id' => 'user:'.$owner->id]);
|
||||
|
||||
return $this->login($owner)->postJson(self::BASE.'/rascunhos', ['creation_key' => (string) Str::uuid(), 'opportunity_category' => 'business_sale',
|
||||
'title' => 'Negócio sintético privado', 'description' => 'Oferta sintética para testar o circuito editorial privado.',
|
||||
'private_offer' => ['contact_id' => $contact, 'price_mode' => 'amount', 'amount' => '12345.67', 'currency' => 'MZN'], 'reason' => self::REASON])->assertCreated()->json('record');
|
||||
}
|
||||
|
||||
private function submit(User $actor, int $id): array
|
||||
{
|
||||
return $this->login($actor)->postJson(self::BASE.'/'.$id.'/submeter', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'confirm' => true])->assertOk()->json('record');
|
||||
}
|
||||
|
||||
private function action(User $actor, int $id, string $action, array $extra = [], int $status = 200): array
|
||||
{
|
||||
return $this->login($actor)->postJson(self::BASE.'/'.$id.'/revisao', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)),
|
||||
'action' => $action, 'reason' => self::REASON, ...$extra])->assertStatus($status)->json('record') ?? [];
|
||||
}
|
||||
|
||||
public function test_submitted_offer_enters_existing_dashboard_and_private_approval_has_exact_snapshot_and_audit(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$reviewer = $this->actor('validator', ['validation_coordinator']);
|
||||
$draft = $this->draft($owner);
|
||||
$submitted = $this->submit($owner, $draft['id']);
|
||||
$this->assertSame('submitted', $submitted['state']);
|
||||
$record = Opportunity::findOrFail($draft['id']);
|
||||
$hash = $record->workflow_context['submission']['hash'];
|
||||
$this->assertSame(1, DB::table('tasks')->where('source_system', 'opportunity-workflow')->count());
|
||||
$this->assertSame(1, app(PortalEditorialWorkflow::class)->opportunityPending($reviewer));
|
||||
$this->login($reviewer)->getJson('/management/editorial')->assertOk()->assertJsonPath('offersUrl', self::BASE)->assertJsonPath('opportunityPending', 1);
|
||||
$this->get(self::BASE.'?state=submitted')->assertInertia(fn (Assert $page) => $page->has('records.data', 1)->where('records.data.0.id', $record->id));
|
||||
$review = $this->action($reviewer, $record->id, 'start_review');
|
||||
$this->assertSame('in_review', $review['state']);
|
||||
$approved = $this->action($reviewer, $record->id, 'approve');
|
||||
$this->assertSame('approved', $approved['state']);
|
||||
$this->assertSame($hash, $approved['approval']['hash']);
|
||||
$this->assertTrue($approved['approvalFresh']);
|
||||
$this->assertFalse($approved['canEdit']);
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $record->id, 'status' => 'draft', 'published_at' => null]);
|
||||
$this->assertDatabaseCount('portal_editorial_editions', 0);
|
||||
$this->assertSame(0, DB::table('tasks')->where('task_type', 'opportunity_publication')->count());
|
||||
$audit = DB::table('audit_events')->where('action', 'workflow_approve')->first();
|
||||
$data = json_decode($audit->after_data, true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertSame($hash, $data['decision']['hash']);
|
||||
$this->assertSame($hash, json_decode(Crypt::decryptString($data['snapshot']), true)['hash']);
|
||||
$this->assertSame(0, Opportunity::published()->count());
|
||||
}
|
||||
|
||||
public function test_rejection_revision_correction_resubmission_preserve_identity_decisions_and_versions(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$reviewer = $this->actor('validator');
|
||||
$id = $this->draft($owner)['id'];
|
||||
$this->submit($owner, $id);
|
||||
$firstHash = Opportunity::findOrFail($id)->workflow_context['submission']['hash'];
|
||||
$this->action($reviewer, $id, 'start_review');
|
||||
$rejected = $this->action($reviewer, $id, 'reject');
|
||||
$this->assertSame('rejected', $rejected['state']);
|
||||
$this->login($owner)->postJson(self::BASE.'/'.$id.'/guardar', ['etag' => $rejected['etag'], 'title' => 'Contorno', 'reason' => self::REASON])->assertForbidden();
|
||||
$revision = $this->action($owner, $id, 'request_revision');
|
||||
$this->assertTrue($revision['canEdit']);
|
||||
$saved = $this->login($owner)->postJson(self::BASE.'/'.$id.'/guardar', ['etag' => $revision['etag'], 'title' => 'Oferta sintética corrigida',
|
||||
'private_offer' => ['price_mode' => 'negotiable', 'amount' => null, 'currency' => null, 'contact_id' => $revision['values']['private_offer']['contact_id']], 'reason' => self::REASON])->assertOk()->json('record');
|
||||
$this->getJson(self::BASE.'/'.$id)->assertJsonPath('record.values.title', 'Oferta sintética corrigida');
|
||||
$resubmitted = $this->submit($owner, $id);
|
||||
$this->assertSame(2, Opportunity::findOrFail($id)->workflow_context['review_cycle']);
|
||||
$resubmission = Opportunity::findOrFail($id)->workflow_context;
|
||||
$this->assertSame($resubmission['submission']['record_version'], $resubmission['last_decision']['submitted_revision']);
|
||||
$this->assertNotSame($firstHash, Opportunity::findOrFail($id)->workflow_context['submission']['hash']);
|
||||
$tasks = DB::table('tasks')->where('source_system', 'opportunity-workflow')->count();
|
||||
$notices = DB::table('portal_editorial_notifications')->count();
|
||||
$this->login($owner)->postJson(self::BASE.'/'.$id.'/submeter', ['etag' => $resubmitted['etag'], 'confirm' => true])->assertOk();
|
||||
$this->assertSame($tasks, DB::table('tasks')->where('source_system', 'opportunity-workflow')->count());
|
||||
$this->assertSame($notices, DB::table('portal_editorial_notifications')->count());
|
||||
$this->action($reviewer, $id, 'start_review');
|
||||
$approved = $this->action($reviewer, $id, 'approve');
|
||||
$this->assertSame(2, count($approved['submittedVersions']));
|
||||
$this->assertSame(self::REASON, $approved['submittedVersions'][0]['rejection']['reason']);
|
||||
$this->assertSame('Negócio sintético privado', $approved['submittedVersions'][0]['submission']['payload']['title']);
|
||||
$this->assertSame('Oferta sintética corrigida', $approved['submittedVersions'][1]['submission']['payload']['title']);
|
||||
$new = $this->action($owner, $id, 'request_revision');
|
||||
$this->assertNull($new['approval']);
|
||||
$this->assertNotNull($new['submittedVersions'][1]['approval']);
|
||||
$this->assertSame(1, Opportunity::count());
|
||||
}
|
||||
|
||||
public function test_return_requires_reason_and_common_field_and_preserves_fields_on_conflict(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$reviewer = $this->actor('validator');
|
||||
$id = $this->draft($owner)['id'];
|
||||
$this->submit($owner, $id);
|
||||
$this->action($reviewer, $id, 'start_review');
|
||||
$etag = app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id));
|
||||
DB::table('opportunities')->where('id', $id)->increment('management_version');
|
||||
$this->action($reviewer, $id, 'approve', ['etag' => $etag], 409);
|
||||
$this->action($reviewer, $id, 'reject', ['reason' => ''], 422);
|
||||
$this->action($reviewer, $id, 'request_correction', ['correction_items' => ['not_a_common_field']], 422);
|
||||
$returned = $this->action($reviewer, $id, 'request_correction', ['correction_items' => ['title']]);
|
||||
$this->assertSame('needs_information', $returned['state']);
|
||||
$this->assertTrue($this->login($owner)->getJson(self::BASE.'/'.$id)->json('record.canEdit'));
|
||||
$this->action($reviewer, $id, 'approve', ['etag' => $etag], 403);
|
||||
$this->assertSame('Negócio sintético privado', Opportunity::findOrFail($id)->title);
|
||||
}
|
||||
|
||||
public function test_author_contributor_wrong_scope_auditor_and_unassigned_reviewer_cannot_approve(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$this->grant($owner, 'validator');
|
||||
$reviewer = $this->actor('validator');
|
||||
$other = $this->actor('validator');
|
||||
$wrongScope = $this->actor('validator', [], ['real_estate']);
|
||||
$auditor = $this->actor('auditor');
|
||||
$id = $this->draft($owner)['id'];
|
||||
$this->submit($owner, $id);
|
||||
$this->action($owner, $id, 'start_review', [], 403);
|
||||
$this->action($wrongScope, $id, 'start_review', [], 403);
|
||||
$this->action($reviewer, $id, 'start_review');
|
||||
$this->action($owner, $id, 'approve', [], 403);
|
||||
$this->action($other, $id, 'approve', [], 403);
|
||||
$this->action($auditor, $id, 'approve', [], 403);
|
||||
$context = Opportunity::findOrFail($id)->workflow_context;
|
||||
DB::table('opportunities')->where('id', $id)->update(['workflow_context' => json_encode([...$context, 'contributors' => [...$context['contributors'], $reviewer->id]])]);
|
||||
$this->action($reviewer, $id, 'approve', [], 403);
|
||||
DB::table('opportunities')->where('id', $id)->update(['workflow_context' => json_encode($context)]);
|
||||
$this->login($reviewer)->withSession(['portal.mfa_at' => time() - 1000])->postJson(self::BASE.'/'.$id.'/revisao', [
|
||||
'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'action' => 'approve', 'reason' => self::REASON])->assertForbidden()->assertHeader('X-Portal-Reauthentication', 'required');
|
||||
DB::table('portal_role_assignments')->where('user_id', $reviewer->id)->update(['revoked_at' => now()]);
|
||||
$this->action($reviewer, $id, 'approve', [], 403);
|
||||
$this->assertNull(Opportunity::findOrFail($id)->workflow_context['approval'] ?? null);
|
||||
}
|
||||
|
||||
public function test_reference_changes_block_approval_and_signal_reconfirmation_without_overwriting_decision(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$reviewer = $this->actor('validator');
|
||||
$draft = $this->draft($owner);
|
||||
$id = $draft['id'];
|
||||
$this->submit($owner, $id);
|
||||
$this->action($reviewer, $id, 'start_review');
|
||||
DB::table('contacts')->where('id', $draft['values']['private_offer']['contact_id'])->update(['full_name' => 'Alteração sintética na fonte']);
|
||||
$this->action($reviewer, $id, 'approve', [], 409);
|
||||
$this->action($reviewer, $id, 'request_correction', ['correction_items' => ['contact']]);
|
||||
$this->submit($owner, $id);
|
||||
$this->action($reviewer, $id, 'start_review');
|
||||
$approved = $this->action($reviewer, $id, 'approve');
|
||||
DB::table('contacts')->where('id', $draft['values']['private_offer']['contact_id'])->update(['full_name' => 'Segunda alteração sintética']);
|
||||
$this->login($owner)->getJson(self::BASE.'/'.$id)->assertJsonPath('record.approvalFresh', false)->assertJsonPath('record.approval.hash', $approved['approval']['hash']);
|
||||
$this->assertTrue($this->action($owner, $id, 'request_revision')['canEdit']);
|
||||
}
|
||||
|
||||
public function test_phase_one_submission_is_bound_only_on_explicit_first_review(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$reviewer = $this->actor('validator');
|
||||
config(['editorial_workflow.enabled' => false]);
|
||||
$id = $this->draft($owner)['id'];
|
||||
$this->submit($owner, $id);
|
||||
$phase1 = Opportunity::findOrFail($id)->workflow_context['private_offer_preparation'];
|
||||
config(['editorial_workflow.enabled' => true]);
|
||||
$this->login($reviewer)->getJson(self::BASE.'/'.$id)->assertOk();
|
||||
$this->assertNull(Opportunity::findOrFail($id)->workflow_context['submission'] ?? null);
|
||||
$this->action($reviewer, $id, 'start_review');
|
||||
$context = Opportunity::findOrFail($id)->workflow_context;
|
||||
$this->assertSame($phase1, $context['phase1_submission']);
|
||||
$this->assertSame($phase1['submitted_at'], $context['submission']['at']);
|
||||
$this->assertSame($phase1['submitted_by'], $context['submission']['actor_id']);
|
||||
$this->assertSame($phase1['submitted_revision'], $context['submission']['record_version']);
|
||||
$this->assertSame('not_recorded', $context['phase1_binding']['original_reference_fingerprint']);
|
||||
$this->action($reviewer, $id, 'approve');
|
||||
}
|
||||
|
||||
public function test_notices_are_scoped_deduplicated_and_use_private_destination_email_rechecks_permission(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$reviewer = $this->actor('validator', ['validation_coordinator']);
|
||||
$foreign = $this->actor('validator', ['validation_coordinator'], ['real_estate']);
|
||||
$id = $this->draft($owner)['id'];
|
||||
$this->submit($owner, $id);
|
||||
$workflow = app(PortalEditorialWorkflow::class);
|
||||
$notice = DB::table('portal_editorial_notifications')->where('user_id', $reviewer->id)->first();
|
||||
$this->assertNotNull($notice);
|
||||
$this->assertSame(0, DB::table('portal_editorial_notifications')->where('user_id', $foreign->id)->count());
|
||||
$workflow->notifyOpportunity($owner, Opportunity::findOrFail($id), 'submit');
|
||||
$this->assertSame(1, DB::table('portal_editorial_notifications')->where('user_id', $reviewer->id)->count());
|
||||
$this->assertSame(self::BASE.'/'.$id, $workflow->notices($reviewer)[0]['href']);
|
||||
config(['portal.notifications_enabled' => true, 'portal.notification_recipients' => [$reviewer->email]]);
|
||||
Mail::swap(new MailManager($this->app));
|
||||
$transport = Mail::mailer('array')->getSymfonyTransport();
|
||||
(new NotifyPrivateOfferDecision($notice->id))->handle();
|
||||
$this->assertCount(1, $transport->messages());
|
||||
DB::table('portal_role_assignments')->where('user_id', $reviewer->id)->update(['revoked_at' => now()]);
|
||||
$this->assertFalse($workflow->visibleNotice($reviewer, $notice));
|
||||
(new NotifyPrivateOfferDecision($notice->id))->handle();
|
||||
$this->assertCount(1, $transport->messages());
|
||||
$this->login($reviewer)->getJson(self::BASE.'/'.$id)->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_publication_and_alternative_routes_remain_blocked_after_private_approval(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$reviewer = $this->actor('validator');
|
||||
$publisher = $this->actor('publisher');
|
||||
$id = $this->draft($owner)['id'];
|
||||
$this->submit($owner, $id);
|
||||
$this->action($reviewer, $id, 'start_review');
|
||||
$this->action($reviewer, $id, 'approve');
|
||||
$this->action($publisher, $id, 'publish', [], 422);
|
||||
$this->login($publisher)->postJson('/minhas-oportunidades/'.$id.'/workflow', ['action' => 'publish'])->assertForbidden();
|
||||
$this->postJson('/minhas-oportunidades/'.$id.'/divulgacao/confirmar')->assertForbidden();
|
||||
$record = Opportunity::findOrFail($id);
|
||||
try {
|
||||
app(OpportunityProfileWorkflow::class)->transition($publisher, $id, ['etag' => app(OpportunityManagement::class)->etag($record), 'action' => 'publish', 'reason' => self::REASON]);
|
||||
$this->fail('Private approval was published.');
|
||||
} catch (HttpException $exception) {
|
||||
$this->assertSame(403, $exception->getStatusCode());
|
||||
}
|
||||
$this->assertSame(0, Opportunity::published()->count());
|
||||
$this->getJson('/api/v1/opportunities/'.$record->slug)->assertNotFound();
|
||||
$this->get('/oportunidades/'.$record->slug)->assertNotFound();
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $id, 'status' => 'draft', 'published_at' => null]);
|
||||
}
|
||||
|
||||
public function test_rollback_preserves_draft_and_does_not_leave_tasks_notices_audit_or_send_mail(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$reviewer = $this->actor('validator', ['validation_coordinator']);
|
||||
$draft = $this->draft($owner);
|
||||
$auditCount = DB::table('audit_events')->count();
|
||||
config(['portal.notifications_enabled' => true, 'portal.notification_recipients' => [$reviewer->email]]);
|
||||
Mail::swap(new MailManager($this->app));
|
||||
$transport = Mail::mailer('array')->getSymfonyTransport();
|
||||
try {
|
||||
DB::transaction(function () use ($owner, $draft, $transport) {
|
||||
app(PrivateOfferPreparation::class)->submit($owner, $draft['id'], ['etag' => $draft['etag'], 'confirm' => true]);
|
||||
$this->assertCount(0, $transport->messages());
|
||||
throw new \RuntimeException('synthetic QA rollback');
|
||||
});
|
||||
$this->fail('The synthetic transaction did not roll back.');
|
||||
} catch (\RuntimeException $error) {
|
||||
$this->assertSame('synthetic QA rollback', $error->getMessage());
|
||||
}
|
||||
$record = Opportunity::findOrFail($draft['id']);
|
||||
$this->assertSame('draft', $record->workflow_context['private_offer_preparation']['state']);
|
||||
$this->assertNull($record->first_submitted_at);
|
||||
$this->assertDatabaseCount('tasks', 0);
|
||||
$this->assertDatabaseCount('portal_editorial_notifications', 0);
|
||||
$this->assertSame($auditCount, DB::table('audit_events')->count());
|
||||
$this->assertCount(0, $transport->messages());
|
||||
}
|
||||
|
||||
public function test_disabling_editorial_does_not_downgrade_a_phase_two_submission_to_phase_one(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$reviewer = $this->actor('validator');
|
||||
$id = $this->draft($owner)['id'];
|
||||
$this->submit($owner, $id);
|
||||
$this->action($reviewer, $id, 'start_review');
|
||||
$this->action($reviewer, $id, 'request_correction', ['correction_items' => ['title']]);
|
||||
$record = Opportunity::findOrFail($id);
|
||||
$before = $record->getRawOriginal();
|
||||
config(['editorial_workflow.enabled' => false]);
|
||||
$this->login($owner)->postJson(self::BASE.'/'.$id.'/submeter', ['etag' => app(OpportunityManagement::class)->etag($record), 'confirm' => true])->assertStatus(503);
|
||||
$this->assertSame($before, $record->fresh()->getRawOriginal());
|
||||
$this->getJson(self::BASE.'/'.$id)->assertOk()->assertJsonPath('record.state', 'needs_information');
|
||||
}
|
||||
|
||||
public function test_existing_coordinator_can_reassign_only_to_a_scoped_independent_validator(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$reviewer = $this->actor('validator');
|
||||
$replacement = $this->actor('validator');
|
||||
$coordinator = $this->actor('validator', ['validation_coordinator']);
|
||||
$wrong = $this->actor('validator', [], ['real_estate']);
|
||||
$id = $this->draft($owner)['id'];
|
||||
$this->submit($owner, $id);
|
||||
$this->action($reviewer, $id, 'start_review');
|
||||
$this->action($owner, $id, 'assign_review', ['assignee_user_id' => $replacement->id], 403);
|
||||
$this->action($coordinator, $id, 'assign_review', ['assignee_user_id' => $wrong->id], 403);
|
||||
$assigned = $this->action($coordinator, $id, 'assign_review', ['assignee_user_id' => $replacement->id]);
|
||||
$this->assertSame('in_review', $assigned['state']);
|
||||
$this->assertSame($replacement->id, Opportunity::findOrFail($id)->workflow_context['assignment']['assignee_user_id']);
|
||||
$assignmentHistory = Opportunity::findOrFail($id)->workflow_context['assignment_history'];
|
||||
$this->assertCount(2, $assignmentHistory);
|
||||
$this->assertSame($reviewer->id, $assignmentHistory[1]['previous_assignee_user_id']);
|
||||
$this->assertSame($replacement->id, $assignmentHistory[1]['assignee_user_id']);
|
||||
$this->action($reviewer, $id, 'approve', [], 403);
|
||||
$this->assertSame('approved', $this->action($replacement, $id, 'approve')['state']);
|
||||
$this->assertDatabaseCount('opportunities', 1);
|
||||
$this->assertSame(1, DB::table('tasks')->where('source_system', 'opportunity-workflow')->count());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user