feat(offers): complete phase five operational controls in QA

This commit is contained in:
2026-11-14 18:08:46 +02:00
parent e431de8b42
commit 528a2597ed
33 changed files with 2383 additions and 19 deletions
@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Models\Opportunity;
use App\Services\OfferAvailability;
use App\Services\PrivateOfferOperations;
use Illuminate\Console\Command;
final class ExpirePrivateOffers extends Command
{
protected $signature = 'investmoz:offers-expire {--confirm : Persist the already effective expiry}';
protected $description = 'Conferir expiração de Ofertas sem publicar conteúdos ou alterar o NDA';
public function handle(): int
{
$count = 0;
Opportunity::query()->whereNotNull('workflow_context->private_offer_preparation')->orderBy('id')->chunkById(100, function ($records) use (&$count) {
foreach ($records as $record) {
if (app(OfferAvailability::class)->record($record)['state'] !== 'expired') {
continue;
}
if ($this->option('confirm')) {
if (app(PrivateOfferOperations::class)->expire($record->id)) {
$count++;
}
} else {
$count++;
}
}
});
$this->info(($this->option('confirm') ? 'Expirações registadas: ' : 'Expiradas, sem escrita: ').$count);
return self::SUCCESS;
}
}
@@ -8,7 +8,7 @@ use App\Services\OpportunityManagement;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class OfferPublicCatalogueController extends Controller
@@ -17,30 +17,30 @@ final class OfferPublicCatalogueController extends Controller
{
$filters = $catalogue->filters($request);
return Inertia::render('offers/catalogue', [...$catalogue->page($request, $filters), 'filters' => $filters,
return $this->uncached(Inertia::render('offers/catalogue', [...$catalogue->page($request, $filters), 'filters' => $filters,
'mapData' => in_array($filters['view'] ?? '', ['map', 'combined'], true) ? $catalogue->map($filters) : null,
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'references' => ['categories' => OpportunityManagement::CATEGORIES, 'options' => [], 'clusters' => [], 'corridors' => [], 'projects' => [], 'locations' => [], 'sectors' => []]]);
'references' => ['categories' => OpportunityManagement::CATEGORIES, 'options' => [], 'clusters' => [], 'corridors' => [], 'projects' => [], 'locations' => [], 'sectors' => []]])->toResponse($request));
}
public function show(int $id, OfferPublicCatalogue $catalogue): Response
public function show(Request $request, int $id, OfferPublicCatalogue $catalogue): Response
{
return Inertia::render('offers/public-detail', ['offer' => $catalogue->detail($id)]);
return $this->uncached(Inertia::render('offers/public-detail', ['offer' => $catalogue->detail($id)])->toResponse($request));
}
public function search(Request $request, OfferPublicCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->page($request, $catalogue->filters($request)));
return response()->json($catalogue->page($request, $catalogue->filters($request)))->header('Cache-Control', 'private, no-store');
}
public function detail(int $id, OfferPublicCatalogue $catalogue): JsonResponse
{
return response()->json(['data' => $catalogue->detail($id)]);
return response()->json(['data' => $catalogue->detail($id)])->header('Cache-Control', 'private, no-store');
}
public function map(Request $request, OfferPublicCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->map($catalogue->filters($request)));
return response()->json($catalogue->map($catalogue->filters($request)))->header('Cache-Control', 'private, no-store');
}
public function export(Request $request, OfferPublicCatalogue $catalogue): StreamedResponse
@@ -60,6 +60,13 @@ final class OfferPublicCatalogueController extends Controller
fputcsv($stream, array_map(fn ($value) => preg_match('/^[\s]*[=+@-]/u', (string) $value) ? "'".$value : $value, $fields), ';', '"', '');
}
fclose($stream);
}, 'ofertas.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'X-Export-Limit' => '1000', 'X-Results-Truncated' => $result['truncated'] ? 'true' : 'false']);
}, 'ofertas.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', 'X-Export-Limit' => '1000', 'X-Results-Truncated' => $result['truncated'] ? 'true' : 'false']);
}
private function uncached(Response $response): Response
{
$response->headers->set('Cache-Control', 'private, no-store');
return $response;
}
}
@@ -107,6 +107,7 @@ final class PortalEditorialWorkflowController extends Controller
$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,
'offersUrl' => isset($destinations['opportunities']['index']) && app(PortalProfileAccess::class)->available() ? '/minhas-oportunidades/ofertas' : null,
'offerOperationsUrl' => ! app(PortalProfileAccess::class)->has($actor, 'auditor') && (app(PortalProfileAccess::class)->has($actor, 'validator') || app(PortalProfileAccess::class)->has($actor, 'publisher')) ? '/minhas-oportunidades/ofertas?operational=1' : 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,
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\PrivateOfferOperations;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
final class PrivateOfferOperationsController extends Controller
{
public function show(Request $request, int $id, PrivateOfferOperations $operations): Response
{
return response()->json($operations->presentation($request->user(), $id));
}
public function owner(Request $request, int $id, PrivateOfferOperations $operations): Response
{
$operations->ownerAction($request->user(), $id, $request->all());
return response()->json($operations->presentation($request->user(), $id));
}
public function reportForm(Request $request, int $id, PrivateOfferOperations $operations): Response
{
$record = Opportunity::findOrFail($id);
abort_unless($record->isPrivateOfferPreparation() && $operations->canReport($request->user(), $record), 404);
return Inertia::render('offers/report', ['offerId' => $id])->toResponse($request);
}
public function report(Request $request, int $id, PrivateOfferOperations $operations): Response
{
return response()->json($operations->report($request->user(), $id, $request->all()), 201);
}
public function handleReport(Request $request, int $id, string $reportId, PrivateOfferOperations $operations): Response
{
$operations->handleReport($request->user(), $id, $reportId, $request->all());
return response()->json($operations->presentation($request->user(), $id));
}
public function highlight(Request $request, int $id, PrivateOfferOperations $operations): Response
{
$operations->highlight($request->user(), $id, $request->all());
return response()->json($operations->presentation($request->user(), $id));
}
}
@@ -3,12 +3,14 @@
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OfferAvailability;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityManagement;
use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityResources;
use App\Services\PortalProfileAccess;
use App\Services\PrivateOfferConfidentiality;
use App\Services\PrivateOfferOperations;
use App\Services\PrivateOfferPreparation;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse;
@@ -86,14 +88,20 @@ 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')
$filters = $request->validate(['state' => 'nullable|in:draft,submitted,in_review,needs_information,rejected,approved', 'operational' => 'nullable|boolean', 'pending_reports' => 'nullable|boolean']);
$query = ($filters['operational'] ?? false) ? app(PrivateOfferOperations::class)->staffQuery($request->user()) : $access->scope(Opportunity::query(), $request->user());
if ($filters['pending_reports'] ?? false) {
$query = app(PrivateOfferOperations::class)->staffQuery($request->user());
$query->whereRaw("jsonb_path_exists(COALESCE(workflow_context->'offer_reports','{}'::jsonb), '$.* ? (@.status == \"open\" || @.status == \"in_review\")')");
}
$records = $query->whereNotNull('workflow_context->private_offer_preparation')
->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' => app(OpportunityManagement::class)->values($r)['title'] ?? null, 'reference' => $r->reference,
'state' => $access->state($r),
'availability' => app(OfferAvailability::class)->record($r)['state'],
]), 'filters' => $filters, 'canCreate' => count($access->navigation($request->user())['contexts']) > 0]);
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use Carbon\CarbonImmutable;
/** Read-time exclusion never depends on the scheduler or the NDA. */
final class OfferAvailability
{
/** @return array{state:string,expires_at:?string,policy_pending:bool} */
public function view(mixed $operation, mixed $publication): array
{
// Legacy public snapshots without an editorial hash retain their first frozen policy.
$key = is_array($publication) ? ($publication['editorial_hash'] ?? '') : null;
$key ??= is_array($operation) ? ($operation['last_approval_hash'] ?? '') : '';
if (! is_string($key)) {
return ['state' => 'unavailable', 'expires_at' => null, 'policy_pending' => true];
}
$policy = is_array($operation) ? ($operation['validity_policies'][$key] ?? $operation['validity'] ?? null) : null;
$pending = ! is_array($policy) || ($policy['days'] ?? null) === null;
$result = ['state' => 'available', 'expires_at' => null, 'policy_pending' => $pending];
if ($operation !== null && ! is_array($operation)) {
return [...$result, 'state' => 'unavailable'];
}
$state = $operation['status'] ?? 'available';
if (! is_int($operation['cycle'] ?? 0) || ($operation['cycle'] ?? 0) < 0) {
return [...$result, 'state' => 'unavailable'];
}
if (! in_array($state, ['available', 'withdrawn', 'expired', 'revalidation'], true)) {
return [...$result, 'state' => 'unavailable'];
}
if ($state !== 'available') {
return [...$result, 'state' => $state];
}
$days = $policy['days'] ?? null;
if (($operation['cycle'] ?? 0) > 0 && ($publication['offer_cycle'] ?? null) !== $operation['cycle']) {
return $result;
}
if ($days === null || ! is_array($publication) || ($publication['publication']['state'] ?? null) !== 'published') {
return $result;
}
if (! is_int($days) || $days < 1 || $days > 36500) {
return [...$result, 'state' => 'unavailable'];
}
try {
$at = $publication['publication']['at'] ?? null;
if (! is_string($at) || ! preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/D', $at)) {
return [...$result, 'state' => 'unavailable'];
}
$start = CarbonImmutable::parse($at);
$end = $start->addDays($days)->utc();
} catch (\Throwable) {
return [...$result, 'state' => 'unavailable'];
}
return [...$result, 'expires_at' => $end->toIso8601String(), 'state' => $end->lte(CarbonImmutable::now()) ? 'expired' : 'available'];
}
/** @return array{state:string,expires_at:?string,policy_pending:bool} */
public function record(Opportunity $record): array
{
return $this->view($record->workflow_context['offer_operation'] ?? null, $record->workflow_context['offer_publication'] ?? null);
}
public function publicAllowed(mixed $operation, mixed $publication): bool
{
if ($this->view($operation, $publication)['state'] !== 'available') {
return false;
}
$cycle = $operation['cycle'] ?? 0;
if (! is_int($cycle) || $cycle < 0) {
return false;
}
// A new validation must never resurrect the previous public snapshot.
return $cycle === 0 || (is_array($publication) && ($publication['offer_cycle'] ?? null) === $cycle
&& is_string($operation['activated_hash'] ?? null) && ($publication['editorial_hash'] ?? null) === $operation['activated_hash']);
}
}
+16 -2
View File
@@ -120,7 +120,7 @@ final class OfferPublicCatalogue
return ['records' => [], 'truncated' => false];
}
$rows = Opportunity::query()->whereNotNull('workflow_context->private_offer_preparation')->whereNotNull('workflow_context->offer_publication')
->orderBy('id')->limit(1001)->get(['id', DB::raw("workflow_context->'offer_publication' AS public_snapshot")]);
->orderBy('id')->limit(1001)->get(['id', DB::raw("workflow_context->'offer_publication' AS public_snapshot"), DB::raw("workflow_context->'offer_operation' AS operation_state")]);
$records = [];
foreach ($rows->take(1000) as $row) {
if (! is_string($row->getAttribute('public_snapshot'))) {
@@ -128,13 +128,21 @@ final class OfferPublicCatalogue
}
try {
$publication = json_decode($row->getAttribute('public_snapshot'), true, 512, JSON_THROW_ON_ERROR);
$operation = json_decode($row->getAttribute('operation_state') ?? 'null', true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $error) {
continue;
}
if (! app(OfferAvailability::class)->publicAllowed($operation, $publication)) {
continue;
}
$p = $this->project($row->id, $publication);
if (! $p) {
continue;
}
$expires = app(OfferAvailability::class)->view($operation, $publication)['expires_at'];
if ($expires !== null) {
$p['expires_at'] = $expires;
}
if (! empty($filters['q']) && ! str_contains(mb_strtolower($p['title'].' '.$p['summary']), mb_strtolower($filters['q']))) {
continue;
}
@@ -184,17 +192,23 @@ final class OfferPublicCatalogue
public function detail(int $id): array
{
abort_unless($this->fixtureReadAllowed(), 404);
$row = Opportunity::query()->whereKey($id)->whereNotNull('workflow_context->private_offer_preparation')->first(['id', DB::raw("workflow_context->'offer_publication' AS public_snapshot")]);
$row = Opportunity::query()->whereKey($id)->whereNotNull('workflow_context->private_offer_preparation')->first(['id', DB::raw("workflow_context->'offer_publication' AS public_snapshot"), DB::raw("workflow_context->'offer_operation' AS operation_state")]);
if (! $row || ! is_string($row->getAttribute('public_snapshot'))) {
abort(404);
}
try {
$publication = json_decode($row->getAttribute('public_snapshot'), true, 512, JSON_THROW_ON_ERROR);
$operation = json_decode($row->getAttribute('operation_state') ?? 'null', true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $error) {
abort(404);
}
abort_unless(app(OfferAvailability::class)->publicAllowed($operation, $publication), 404);
$record = $this->project($id, $publication);
abort_unless($record !== null, 404);
$expires = app(OfferAvailability::class)->view($operation, $publication)['expires_at'];
if ($expires !== null) {
$record['expires_at'] = $expires;
}
return $record;
}
+8 -2
View File
@@ -116,7 +116,9 @@ class OpportunityProfileWorkflow
if (in_array($state, ['draft', 'needs_information'], true)) {
$actions[] = 'submit';
}
if (in_array($state, ['rejected', 'approved'], true)) {
if (in_array($state, ['rejected', 'approved'], true) || (isset($record->workflow_context['submission']) && ($record->workflow_context['offer_operation']['status'] ?? null) === 'revalidation'
&& ($record->workflow_context['offer_operation']['requested_by'] ?? null) === $actor->id && (int) $record->owner_user_id === $actor->id
&& in_array($state, ['draft', 'needs_information', 'submitted', 'in_review', 'information_pending'], true))) {
$actions[] = 'request_revision';
}
}
@@ -232,7 +234,8 @@ class OpportunityProfileWorkflow
'review_gate' => ['submitted', 'in_review', 'information_pending', 'approved'],
'publish' => ['approved'], 'suspend' => [$state],
'assign_publication' => ['approved'],
'request_revision' => $offer ? ['approved', 'rejected'] : ['approved', 'published', 'suspended'],
'request_revision' => $offer ? (($context['offer_operation']['status'] ?? null) === 'revalidation'
? ['draft', 'needs_information', 'submitted', 'in_review', 'information_pending', 'approved', 'rejected'] : ['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'],
@@ -378,6 +381,9 @@ class OpportunityProfileWorkflow
&& $this->responsibility($reviewer, $record, preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1 ? 'legal_reviewer' : 'technical_reviewer'), 403, 'Revisor revogado, sem responsabilidade ou com conflito.');
}
$context['approval'] = [...$decision, 'hash' => $snapshot['hash'], 'gates' => $context['reviews'] ?? []];
if ($offer) {
$context['offer_operation'] = app(PrivateOfferOperations::class)->afterApproval($context);
}
$this->tasks->complete($context['review_task_id'] ?? null, $data['reason']);
$record->workflow_context = $context;
if (! $offer) {
@@ -61,6 +61,7 @@ final class PrivateOfferConfidentiality
private function approvedHash(Opportunity $record): string
{
abort_unless(app(OfferAvailability::class)->record($record)['state'] === 'available', 409, 'A oferta não está disponível.');
$hash = $record->workflow_context['approval']['hash'] ?? null;
abort_unless(is_string($hash) && app(PortalProfileAccess::class)->state($record) === 'approved', 409, 'Esta versão não tem aprovação editorial actual.');
try {
+296
View File
@@ -0,0 +1,296 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/** Availability, reports and administrative plans in the existing opportunity and CRM tasks. */
final class PrivateOfferOperations
{
/** @param array<string,mixed> $context
* @return array<string,mixed> */
public function afterApproval(array $context): array
{
$days = config('private_offers.validity_days');
$reference = config('private_offers.validity_policy_reference');
if ($days !== null && (! is_int($days) || $days < 1 || $days > 36500 || ! is_string($reference) || trim($reference) === '')) {
throw ValidationException::withMessages(['validity' => 'Configuração de validade incompleta. Não foi aplicado um prazo presumido.']);
}
$operation = $context['offer_operation'] ?? ['status' => 'available', 'cycle' => 0];
$hash = $context['approval']['hash'];
$policy = ['days' => $days, 'policy_reference' => $reference, 'approval_hash' => $hash];
$operation['validity_policies'][$hash] ??= $policy;
$operation['validity'] ??= $operation['validity_policies'][$hash];
$operation['last_approval_hash'] = $hash;
return $operation;
}
private function offer(int $id, bool $lock = false): Opportunity
{
$record = Opportunity::query()->when($lock, fn ($q) => $q->lockForUpdate())->findOrFail($id);
abort_unless($record->isPrivateOfferPreparation(), 404);
return $record;
}
public function publisher(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
return $access->canView($actor, $record) && $access->mfa($actor) && ! $access->has($actor, 'auditor') && ! $access->conflict($actor, $record)
&& $access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'publish', $record->id);
}
/** @return Builder<Opportunity> */
public function staffQuery(User $actor): Builder
{
$access = app(PortalProfileAccess::class);
abort_unless($access->mfa($actor) && ! $access->has($actor, 'auditor'), 403);
$grants = [...$access->grants($actor, 'validator'), ...$access->grants($actor, 'publisher')];
abort_unless($grants !== [], 403);
return $access->scope(Opportunity::query(), $actor)->whereNotNull('workflow_context->private_offer_preparation')
->where('owner_user_id', '!=', $actor->id)->whereRaw("NOT COALESCE(workflow_context->'contributors','[]'::jsonb) @> ?::jsonb", [json_encode([$actor->id])])
->where(fn ($q) => $q->whereNull('organization_id')->orWhereNotIn('organization_id', DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->select('organization_id')))
->where(function ($q) use ($grants) {
foreach ($grants as $grant) {
$q->orWhere(function ($scope) use ($grant) {
$scope->whereIn('opportunity_category', json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR));
if ($grant->organization_id !== null) {
$scope->where('organization_id', $grant->organization_id);
}
});
}
});
}
/** @return array<string,mixed> */
public function presentation(User $actor, int $id): array
{
$record = app(PrivateOfferPreparation::class)->record($actor, $id);
$reviewer = app(PrivateOfferConfidentiality::class)->internal($actor, $record);
$publisher = $this->publisher($actor, $record);
$reports = [];
if ($reviewer) {
foreach ($record->workflow_context['offer_reports'] ?? [] as $report) {
$reports[] = [...$report, 'reason' => Crypt::decryptString($report['reason']), 'resolution_reason' => isset($report['resolution_reason']) ? Crypt::decryptString($report['resolution_reason']) : null,
'source_snapshot' => isset($report['source_snapshot']) ? json_decode(Crypt::decryptString($report['source_snapshot']), true, 512, JSON_THROW_ON_ERROR) : null];
}
}
$highlight = $record->workflow_context['offer_highlight'] ?? null;
return ['id' => $id, 'etag' => app(OpportunityManagement::class)->etag($record),
'availability' => app(OfferAvailability::class)->record($record), 'cycle' => $record->workflow_context['offer_operation']['cycle'] ?? 0,
'owner' => app(PrivateOfferConfidentiality::class)->seller($actor, $record), 'reviewer' => $reviewer, 'publisher' => $publisher,
'editorial_state' => app(PortalProfileAccess::class)->state($record), 'reports' => $reports,
'highlight' => $publisher && $highlight ? [...$highlight, 'note' => Crypt::decryptString($highlight['note'])] : null,
'commercial_activation' => false,
'history' => array_map(fn ($event) => array_intersect_key($event, array_flip(['id', 'action', 'actor_id', 'at', 'cycle'])), $record->workflow_context['offer_operation_history'] ?? [])];
}
/** @param array<string,mixed> $context
* @param array<string,mixed> $metadata */
private function save(Opportunity $record, ?User $actor, array $context, string $action, array $metadata = []): void
{
$event = ['id' => (string) Str::uuid(), 'action' => $action, 'actor_id' => $actor?->id, 'at' => now()->toIso8601String(),
'cycle' => $context['offer_operation']['cycle'] ?? 0, ...$metadata];
$context['offer_operation_history'][] = $event;
$record->workflow_context = $context;
$record->management_version++;
$record->save();
DB::table('audit_events')->insert(['source_system' => 'private-offers', 'entity_type' => 'opportunity', 'actor_user_id' => $actor?->id,
'action' => $action, 'data_classification' => 'restricted', 'correlation_id' => (string) Str::uuid(),
'after_data' => json_encode(['record_id' => $record->id, 'record_version' => $record->management_version, ...$event], JSON_THROW_ON_ERROR)]);
}
/** @param array<string,mixed> $context
* @return array<string,mixed> */
private function withdraw(array $context, string $origin): array
{
$context['offer_operation'] = [...($context['offer_operation'] ?? ['cycle' => 0]), 'status' => 'withdrawn', 'withdrawn_at' => now()->toIso8601String(), 'origin' => $origin];
return $context;
}
/** @param array<string,mixed> $input */
public function ownerAction(User $actor, int $id, array $input): Opportunity
{
$data = validator($input, ['action' => 'required|in:withdraw,request_reactivation,reactivate', 'confirm' => 'required|accepted',
'etag' => 'required_unless:action,withdraw|string|size:64', 'reason' => 'nullable|string|max:1000'])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->offer($id, true);
abort_unless(app(PrivateOfferConfidentiality::class)->seller($actor, $record), 403);
$context = $record->workflow_context;
$state = app(OfferAvailability::class)->record($record)['state'];
if ($data['action'] === 'withdraw') {
if (($context['offer_operation']['status'] ?? null) === 'withdrawn') {
return $record;
}
$this->save($record, $actor, $this->withdraw($context, 'owner'), 'offer_withdrawn', ['reason' => Crypt::encryptString($data['reason'] ?? '')]);
return $record;
}
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409);
if ($data['action'] === 'request_reactivation') {
abort_unless(in_array($state, ['withdrawn', 'expired'], true), 409, 'A oferta não está retirada ou expirada.');
$editorialState = app(PortalProfileAccess::class)->state($record);
abort_unless(isset($context['submission']) || in_array($editorialState, ['draft', 'needs_information'], true), 409,
'A preparação anterior ainda precisa da vinculação editorial existente antes de abrir nova revisão.');
$context['offer_operation'] = [...($context['offer_operation'] ?? []), 'status' => 'revalidation', 'cycle' => ($context['offer_operation']['cycle'] ?? 0) + 1,
'requested_by' => $actor->id, 'requested_at' => now()->toIso8601String()];
// Reopen the same editorial engine, retaining old submissions and decisions.
$record->workflow_context = $context;
$record->save();
if (isset($context['submission'])) {
$record = app(OpportunityProfileWorkflow::class)->transition($actor, $id, ['etag' => app(OpportunityManagement::class)->etag($record),
'action' => 'request_revision', 'reason' => 'Pedido explícito de nova validação para reactivar a oferta. '.($data['reason'] ?? '')]);
}
$this->save($record, $actor, $record->workflow_context, 'offer_reactivation_requested');
return $record;
}
abort_unless($state === 'revalidation' && app(PortalProfileAccess::class)->state($record) === 'approved', 409, 'É necessária nova validação editorial.');
$snapshot = app(PrivateOfferPreparation::class)->snapshot($record);
abort_unless(($snapshot['payload']['operational_cycle'] ?? null) === $context['offer_operation']['cycle']
&& hash_equals($context['approval']['hash'] ?? '', $snapshot['hash'])
&& ($context['submission']['payload']['operational_cycle'] ?? null) === $context['offer_operation']['cycle'], 409);
$context['offer_operation']['status'] = 'available';
$context['offer_operation']['activated_hash'] = $snapshot['hash'];
$this->save($record, $actor, $context, 'offer_reactivated_privately');
return $record;
});
}
public function expire(int $id): bool
{
return DB::transaction(function () use ($id) {
$record = $this->offer($id, true);
$availability = app(OfferAvailability::class)->record($record);
if ($availability['state'] !== 'expired' || ($record->workflow_context['offer_operation']['status'] ?? null) === 'expired') {
return false;
}
$context = $record->workflow_context;
$context['offer_operation'] = [...($context['offer_operation'] ?? []), 'status' => 'expired', 'expired_at' => $availability['expires_at']];
$this->save($record, null, $context, 'offer_expired', ['expires_at' => $availability['expires_at']]);
return true;
});
}
public function canReport(User $actor, Opportunity $record): bool
{
if (app(PortalProfileAccess::class)->canView($actor, $record)) {
return true;
}
try {
app(OfferPublicCatalogue::class)->detail($record->id);
return true;
} catch (HttpExceptionInterface $error) {
return false;
}
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function report(User $actor, int $id, array $input): array
{
$data = validator($input, ['client_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:2000', 'confirm' => 'required|accepted'])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->offer($id, true);
$context = $record->workflow_context;
$key = $data['client_key'];
$hash = hash('sha256', $data['reason']);
if (isset($context['offer_reports'][$key])) {
$previous = $context['offer_reports'][$key];
abort_unless($previous['reporter_id'] === $actor->id, 404);
abort_unless($previous['command_hash'] === $hash, 409);
return ['id' => $key, 'replayed' => true];
}
abort_unless($this->canReport($actor, $record), 404);
try {
$source = app(OfferPublicCatalogue::class)->detail($id);
} catch (HttpExceptionInterface $error) {
$source = Arr::only(app(OpportunityManagement::class)->values($record), ['title', 'description', 'opportunity_category', 'private_offer']);
}
$task = app(OpportunityWorkflowTasks::class)->ensure($record, 'offer_report_'.$key, 'Denúncia da Oferta '.$record->reference);
$context['offer_reports'][$key] = ['id' => $key, 'reporter_id' => $actor->id, 'at' => now()->toIso8601String(), 'command_hash' => $hash,
'reason' => Crypt::encryptString($data['reason']), 'version' => 1, 'status' => 'open', 'task_id' => $task,
'source_snapshot' => Crypt::encryptString(json_encode($source, JSON_THROW_ON_ERROR)),
'source_record_version' => $record->management_version, 'source_approval_hash' => $context['approval']['hash'] ?? null,
'source_public_hash' => $context['offer_publication']['publication']['hash'] ?? null];
$this->save($record, $actor, $context, 'offer_reported', ['report_id' => $key]);
return ['id' => $key, 'replayed' => false];
});
}
/** @param array<string,mixed> $input */
public function handleReport(User $actor, int $id, string $reportId, array $input): void
{
$data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'action' => 'required|in:start_review,resolve_no_withdrawal,resolve_withdrawal',
'reason' => 'required|string|min:20|max:2000', 'confirm' => 'required|accepted'])->validate();
DB::transaction(function () use ($actor, $id, $reportId, $data) {
$record = $this->offer($id, true);
abort_unless(app(PrivateOfferConfidentiality::class)->internal($actor, $record), 403);
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409);
$context = $record->workflow_context;
$report = $context['offer_reports'][$reportId] ?? null;
abort_unless($report !== null, 404);
abort_unless($report['version'] === (int) $data['version'] && in_array($report['status'], ['open', 'in_review'], true), 409);
if ($data['action'] === 'start_review') {
abort_unless($report['status'] === 'open', 409);
app(OpportunityWorkflowTasks::class)->assign($record, $report['task_id'], $actor);
$report['status'] = 'in_review';
} else {
abort_unless($report['status'] === 'in_review' && $report['reviewer_id'] === $actor->id, 409, 'A denúncia deve ser tratada pelo revisor que a iniciou.');
app(OpportunityWorkflowTasks::class)->complete($report['task_id'], 'Denúncia tratada; decisão no histórico privado.');
$report['status'] = 'resolved';
$report['resolution'] = $data['action'];
if ($data['action'] === 'resolve_withdrawal') {
$context = $this->withdraw($context, 'moderation');
}
}
$report['reviewer_id'] = $actor->id;
$report['version']++;
$report['updated_at'] = now()->toIso8601String();
$report['resolution_reason'] = Crypt::encryptString($data['reason']);
$report['history'][] = ['action' => $data['action'], 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'reason' => Crypt::encryptString($data['reason'])];
$context['offer_reports'][$reportId] = $report;
$this->save($record, $actor, $context, 'offer_report_'.$data['action'], ['report_id' => $reportId]);
});
}
/** @param array<string,mixed> $input */
public function highlight(User $actor, int $id, array $input): void
{
$data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:0', 'status' => 'required|in:draft,archived',
'label' => 'required|string|max:150', 'starts_at' => 'nullable|date', 'ends_at' => 'nullable|required_with:starts_at|date|after:starts_at',
'note' => 'required|string|min:20|max:1000', 'confirm' => 'required|accepted'])->validate();
DB::transaction(function () use ($actor, $id, $data) {
$record = $this->offer($id, true);
abort_unless($this->publisher($actor, $record), 403);
$context = $record->workflow_context;
$before = $context['offer_highlight'] ?? null;
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']) && (int) $data['version'] === ($before['version'] ?? 0), 409);
$plan = ['version' => (int) $data['version'] + 1, 'status' => $data['status'], 'label' => $data['label'], 'starts_at' => $data['starts_at'] ?? null,
'ends_at' => $data['ends_at'] ?? null, 'note' => Crypt::encryptString($data['note']), 'actor_id' => $actor->id, 'at' => now()->toIso8601String()];
$context['offer_highlight_history'][] = $before;
$context['offer_highlight'] = $plan;
$this->save($record, $actor, $context, 'offer_highlight_prepared', ['plan_version' => $plan['version'], 'status' => $plan['status']]);
});
}
}
+5 -1
View File
@@ -154,6 +154,10 @@ final class PrivateOfferPreparation
if ($extended) {
$payload['resources'] = $resources;
}
$cycle = $record->workflow_context['offer_operation']['cycle'] ?? 0;
if ($cycle > 0) {
$payload['operational_cycle'] = $cycle;
}
$normalize = function (mixed $value) use (&$normalize): mixed {
if (is_object($value)) {
$value = get_object_vars($value);
@@ -169,7 +173,7 @@ final class PrivateOfferPreparation
};
$payload = $normalize($payload);
$contract = $extended ? 'private-offer-editorial-v3' : 'private-offer-editorial-v2';
$contract = $cycle > 0 ? 'private-offer-editorial-v4' : ($extended ? 'private-offer-editorial-v3' : 'private-offer-editorial-v2');
return ['payload' => $payload, 'hash' => hash('sha256', json_encode(['contract' => $contract, 'payload' => $payload], JSON_THROW_ON_ERROR)),
'record_version' => $record->management_version, 'contract_version' => $contract, 'resources' => $resources];
+4
View File
@@ -3,4 +3,8 @@
return [
// No legal text or operational NDA is supplied/approved. Never infer it from an upload.
'nda' => null,
// No definitive publication lifetime was decided. Freeze an explicit policy on approval.
'validity_days' => null,
'validity_policy_reference' => null,
'expiry_scheduler_enabled' => false,
];
@@ -10185,3 +10185,17 @@ Controlo empresarial exige aceitação do exemplar exacto do NDA e autorização
Os cinco testes territoriais foram investigados: dependiam de Inhambane/turismo previamente importados numa base vazia. O fixture cria apenas os mínimos sintéticos quando faltam, com guarda testing/loopback/*_test:55441 e transacção de rollback. Os seis casos passaram; nenhum cadastro operacional foi alterado. Os snapshots F4 vinculam particularidades/recursos ao contrato v3; Ofertas comuns anteriores conservam v2.
Estado: implementado em QA, sem instalação, publicação efectiva, pagamentos, destaque comercial, migrações ou alterações em mne. Fase 5 não iniciada; módulo integral/Procuras parciais. Pendências: exemplar/versão/aprovação do NDA, identificação jurídica dos comprovativos admitidos, visual desktop/móvel e email real anteriores. GD ausente: derivadas do processador nativo continuam bloqueadas, sem simular processamento. Formatação global mantém 61 ficheiros exteriores ao delta. Relatório: docs/investmoz/v2/auditoria-portal/ofertas-fase4-entrega-qa-20261114.md. Recibo: outputs/offers-private-phase4-20261114/qa-result.json; contém resultados e hashes finais, sem somar execuções sobrepostas. Próximo passo: apreciação QA e disponibilização do NDA aprovado, mantendo o bloqueio de publicação.
## 14/11/2026 — Ofertas Fase 5, fecho operacional em QA
Base aceite com ressalvas e431de8b preservada, tal como 7691cab3, 91c18573, 1f2990e4 e plano6e302db6. F5 autorizada exclusivamente em QA. O dono pode retirar disponibilidade imediatamente, sem aprovação editorial ou bloqueio por etag antigo. Retirada/expiração excluem leituras públicas e fecham dados protegidos por NDA. Conteúdos, identificadores, originais e decisões não são apagados.
Reactivar exige pedido no mesmo cadastro, novo ciclo operacional e nova submissão/revisão/aprovação no motor existente; só depois o dono repõe disponibilidade privada. O snapshot v4 inclui o ciclo, preservando v2/v3 anteriores. Uma publicação antiga não reaparece e grants NDA antigos não reabrem por causa dessa reactivação. A reposição de disponibilidade não publica.
Validade independente do NDA: validity_days e validity_policy_reference inicialmente nulos, sem prazo definitivo presumido. Política congelada por hash; início na publicação da versão; configuração ou aprovação privada posterior não prolongam o exemplar público anterior. Expiração verificada em cada leitura, sem depender do agendador. API/cartão apresentam data apenas quando conhecida; CSV conserva o formato anterior e a mesma exclusão. Comando investmoz:offers-expire confere sem escrita; --confirm materializa com auditoria/idempotência. Agendamento permanece desligado.
Denúncias fundamentadas ficam cifradas com origem, actor e versão, ligadas a tarefas CRM e ao Dashboard Colaborativo existentes. A entrada Ofertas — acompanhamento usa a mesma listagem privada e filtro de denúncias pendentes. Validador no âmbito, com MFA e sem conflito, analisa e conclui com fundamento, com ou sem retirada. Etags e versão da denúncia protegem a decisão; o proprietário não recebe os dados originais do denunciante. Retomas devolvem o recibo sem outra tarefa. Planos administrativos de destaque têm versão/histórico e só rascunho/arquivo, sob Publicador autorizado; não alteram is_featured, cobram ou executam pagamentos.
Verificação: percurso novo inicialmente com 12 testes/151 asserções aprovados; regressão integrada final com 59 testes/780 asserções aprovados, com resultados no recibo outputs/offers-private-phase5-20261114/qa-result.json. Grupos sobrepostos não devem ser somados. Relatório técnico e matriz de aceitação das cinco fases: docs/investmoz/v2/auditoria-portal/ofertas-fase5-fecho-qa-20261114.md. A matriz RF-MN-003 existente foi actualizada, sem novo inventário geral.
Estado: cinco fases preparadas em QA; F1-F4 aceites tecnicamente com ressalvas, F5 para apreciação; nenhuma instalação ou publicação efectiva. Mantêm-se pendências NDA/exemplar/versão/aprovação, tipos jurídicos de comprovativos, prazo final, avisos jurídicos, visual desktop/móvel, GD/WebP e email real F2. Formatação global mantém61ficheiros preexistentes fora do delta. NDA mantém configuração nula; sem migrações, alterações em mne, dados operacionais, pagamentos ou promoção comercial. Procuras/restante portal não encerrados por este lote. Próximo passo: apreciação QA e decisões documentais; instalação/activação/publicação aguardam autorização expressa.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,110 @@
# Ofertas — Fase 5: fecho operacional em QA e aceitação das cinco fases
14/11/2026. Autorização expressa da Tempo para desenvolvimento exclusivo em QA. Base aceite com ressalvas: `e431de8b` (Fase 4). Mantêm-se `7691cab3`, `91c18573`, `1f2990e4` e o plano `6e302db6`. Referência funcional: RF-MN-003 da matriz existente.
**Fecho operacional implementado em QA. Publicação efectiva continua bloqueada. Nenhuma instalação em homologação/produção, alteração de dados operacionais, migração ou modificação em `mne`.** A aceitação desta entrega não é inferida da autorização para a desenvolver.
## Disponibilidade, retirada e reactivação
O dono registado da Oferta, com atribuição e representação ainda efectivas, pode retirar imediatamente a disponibilidade. A operação protectora não exige decisão editorial, MFA adicional do promotor ou uma versão de formulário actual: bloqueia o registo, reconfirma o dono e retira a versão corrente. Uma repetição não duplica a retirada. Não altera o conteúdo, apaga decisões ou converte o cadastro noutro produto.
Disponibilidade e estado editorial são distintos. Uma aprovação privada pode continuar registada, mas a retirada exclui a Oferta das consultas, detalhe, filtros, mapa e CSV e fecha a leitura empresarial protegida pelo NDA. Uma consulta anterior já entregue ao utilizador não pode ser recolhida retroactivamente.
Para voltar a disponibilizar, o proprietário pede nova validação no mesmo identificador. Abre-se um ciclo operacional que integra o hash do snapshot editorial (`private-offer-editorial-v4`); os contratos v2/v3 anteriores mantêm-se quando não há esse ciclo. O motor editorial existente conserva submissões, responsáveis, tarefas e decisões, reabre a preparação e exige submissão/revisão/aprovação independente. Só depois o proprietário pode repor a disponibilidade privada. Uma aprovação anterior ou um pedido de reactivação não bastam.
Uma retirada durante revisão também pode abrir esse novo ciclo, fechando as tarefas anteriores pelo mecanismo existente. Um primeiro rascunho retirado continua a exigir a sua primeira validação. Preparações F1 antigas sem snapshot seguem primeiro a vinculação editorial já existente, sem fabricar um histórico de referências anterior.
Reactivar disponibilidade **não publica**. O consumidor não reapresenta o snapshot público antigo: para um ciclo novo, exige a correspondência de `offer_cycle` e `editorial_hash` com o ciclo e a aprovação activados. Esses campos são o contrato de leitura futura, não um publicador. Nos testes, apenas projecções sintéticas são disponibilizadas por injecção interna na base isolada.
## Validade independente do NDA
`config/private_offers.php` acrescenta `validity_days` e `validity_policy_reference`, ambos inicialmente nulos. Não foi escolhido um prazo definitivo. Quando a Tempo definir um prazo, a configuração exige inteiro positivo e referência identificada; o limite técnico de 36.500 dias protege a aritmética e não é uma política comercial/jurídica.
A política é congelada por hash na aprovação, e a contagem começa no instante UTC da publicação dessa versão. A aprovação privada não inicia a contagem. Alterar a configuração ou aprovar uma edição privada não prolonga a validade do snapshot público anterior; o exemplar legado sem `editorial_hash` conserva a primeira política congelada. Uma política pendente não é substituída por zero ou por um prazo presumido.
No instante `agora >= publicação + duração`, a Oferta deixa de aparecer nas leituras. Essa exclusão não depende de cron, execução de um job, aceitação ou duração do NDA. A API acrescenta `expires_at` apenas quando a data é conhecida, e o cartão público mostra-a. O formato anterior do CSV foi preservado; os seus registos usam a mesma exclusão por disponibilidade/expiração. As respostas públicas de Ofertas usam `no-store` para não conservar uma versão retirada em caches HTTP.
O comando `investmoz:offers-expire` confere por defeito **sem escrita**. Com `--confirm`, materializa uma expiração já efectiva e regista o evento uma vez, com actor de sistema nulo, sem criar utilizadores. O agendamento é optativo por `expiry_scheduler_enabled`, que permanece **false**. Não foi activado no ambiente operacional. Os ensaios do comando ocorreram exclusivamente na base de testes guardada.
## Denúncias e Dashboard Colaborativo
A Oferta dispõe de formulário de denúncia fundamentada. O utilizador deve estar autenticado, com email verificado, e poder consultar a versão pública ou já ter acesso privado legítimo ao registo. Um estranho não usa este percurso para descobrir um rascunho. A fundamentação é obrigatória; limites técnicos de texto e o throttle existente limitam os pedidos.
Cada envio guarda identificador idempotente, autor, instante, versão do registo, hashes de referência e cópia cifrada do conteúdo referido. O fundamento também fica cifrado. Uma retoma com o mesmo actor/chave/conteúdo devolve o recibo sem criar outra tarefa, mesmo que entretanto a Oferta tenha sido retirada. Reutilizar a chave para outro conteúdo é conflito; outro actor não recebe o recibo.
O mesmo `OpportunityWorkflowTasks` cria uma tarefa CRM restrita, sem inventar oportunidade CRM, responsável ou transporte. A entrada **Ofertas — acompanhamento**, dentro do Dashboard Colaborativo existente, abre a listagem privada existente em modo operacional, com filtro de denúncias pendentes. Não há outro dashboard nem alteração do menu principal.
O validador interno com âmbito, MFA recente e sem conflito inicia a análise; fica associado à tarefa e à versão da denúncia. Conclui com fundamento, sem retirada ou com retirada da disponibilidade. Etag, versão da denúncia, estado e responsável são reconferidos no servidor sob bloqueio. O dono, auditor, outro âmbito ou outra conta não substituem essa autorização. A conclusão preserva os factos originais e o histórico; não é apresentada como decisão judicial ou certificação de fraude.
O proprietário não recebe o fundamento original ou os dados do denunciante pela API de acompanhamento. A leitura desses dados exige o papel interno referido. A origem cifrada permite conferir a versão denunciada, mesmo que a preparação corrente seja alterada. Notificações, tarefas, pesquisa pública, mapa e CSV não recebem o texto da denúncia.
## Preparação administrativa de destaques
O Publicador existente, com âmbito, MFA, autorização efectiva e sem conflito, pode guardar uma preparação administrativa com designação, datas previstas, fundamento e estado **rascunho/arquivado**. O plano tem versão, etag, histórico e auditoria próprios no contexto da mesma Oferta.
Não há estado comercial activo, alteração de `is_featured`, preço de destaque, cobrança, pagamento ou integração financeira. O proprietário e o público não recebem esse plano. A actualização de disponibilidade/denúncias preserva o plano local em edição; carregar o plano guardado exige uma acção explícita que identifica a substituição dos campos locais.
## Segurança, confidencialidade e compatibilidade
- Operações privadas reutilizam autenticação, perfis, representação, MFA, CSRF, limites de pedidos e respostas `no-store` existentes. As escritas usam transacções/bloqueio do registo; versões e etags impedem decisões sobre uma revisão desconhecida. A única dispensa de etag é a retirada protectora pelo dono, para não manter uma exposição por causa de um formulário antigo.
- A retirada/expiração fecha imediatamente a leitura protegida da empresa. Uma nova validação tem hash/ciclo novo; não reaproveita silenciosamente a aceitação/autorização NDA anterior. O interessado conserva a possibilidade de retirar uma aceitação antiga durante revisão. Não se apagaram os seus registos históricos.
- A Fase 4 mantém comprovativos apenas para validadores internos autorizados, originais cifrados, limite de seis fotografias, quarentena e análise de segurança. Não se acrescentou partilha externa de originais ou coordenadas exactas.
- A projecção pública continua separada da edição privada; a barreira normal continua fechada. APIs nativas dos outros produtos não passam a devolver Ofertas. Filtros, detalhe, mapas e CSV de Ofertas usam a mesma condição de disponibilidade e expiração. A propriedade opcional `expires_at` não altera as chaves dos exemplares antigos sem validade definida.
- Auditoria conserva actor, instante, versão, ciclo e identificadores de decisão. Fundamentos e origem das denúncias ficam cifrados; não há valores financeiros, contactos ou documentos nos títulos das tarefas ou nos eventos públicos.
## Verificação
Base: `offers_phase1_20261114_test`, loopback, porta **55441**, guardas existentes e dados exclusivamente sintéticos. Não foram utilizados processos reais, credenciais de negócio, publicação real ou entrega externa de email. O scanner sintético da Fase 4 mantém a identificação de teste e não foi registado no produto.
Os resultados e hashes finais encontram-se em `outputs/offers-private-phase5-20261114/qa-result.json`. A passagem inicial focada teve **12 testes/151 asserções aprovados**. A regressão integrada terminou com **59 testes/780 asserções aprovados**, F1–F5, com os assets estabilizados. Os grupos sobrepostos não devem ser somados.
| Grupo da passagem integrada | Testes | Asserções | Resultado |
| --- | ---: | ---: | --- |
| F1 — preparação privada | 14 | 146 | Aprovado |
| F2 — circuito editorial | 11 | 161 | Aprovado |
| F3 — consulta e filtros | 10 | 133 | Aprovado |
| F4 — particularidades/confidencialidade | 11 | 160 | Aprovado |
| F5 — fecho operacional | 13 | 180 | Aprovado |
| **Total desta passagem, sem somar a inicial** | **59** | **780** | **Aprovado** |
Pint, PHPStan sem erros, TypeScript, lint/formatação dos oito ficheiros frontend e compilação QA passaram. A formatação global falhou nos mesmos 61 ficheiros preexistentes, fora do delta e com conteúdo Git inalterado. Não foram enfraquecidos testes, adicionadas excepções de análise ou instaladas dependências. A primeira passagem delimitada revelou uma recusa da antiga lista de acções à reabertura durante revisão: foi corrigida exclusivamente para o ciclo operacional pedido pelo dono, mantendo as regras dos restantes percursos.
A prova cobre retirada com formulário antigo, repetição, actor/âmbito incorrectos, novo ciclo e nova validação, ausência de reexposição do snapshot antigo, primeira preparação, retirada durante revisão, preservação do histórico, fronteira exacta da expiração, política nula/configuração congelada, NDA anterior fechado, idempotência da denúncia, fonte cifrada, tarefa/filas nativas, tratamento e conflitos de versão, MFA expirado, auditor, destaque administrativo sem efeitos comerciais e HTML compilado das páginas afectadas.
## Matriz de aceitação das cinco fases
| Fase | Resultado demonstrado em QA | Referência aceite/prova | Instalação/publicação | Ressalvas e pendências |
| --- | --- | --- | --- | --- |
| 1 — Preparação privada | Criar, guardar, recuperar, editar e submeter; autorização/isolamento; cadastro de Oportunidades reutilizado | `1f2990e4`; `PrivateOfferPreparationTest` | Não instalada | Visual pendente |
| 2 — Circuito editorial | Dashboard existente, revisão, rejeição/correcção/reenvio, aprovação por versão, auditoria e notificações internas | `91c18573`, plano `6e302db6`; `PrivateOfferEditorialTest` | Não instalada; aprovação distinta de publicação | Visual e entrega real de email pendentes |
| 3 — Consulta e filtros | Projecção aprovada/publicada sintética, categorias/localização/preços sem câmbio, mapa administrativo, API/CSV e preservação da versão anterior | `7691cab3`; `OfferPublicCatalogueTest` e compatibilidade F5 | Publicação efectiva bloqueada | Visual/móvel; abertura real não autorizada; limite de representação de 1.000 candidatos/30 territórios preservado |
| 4 — Particularidades e confidencialidade | Campos confirmados, seis fotografias, comprovativos internos, duas condições NDA/vendedor, revogação/histórico e acessos por versão | `e431de8b`; `PrivateOfferConfidentialityTest` | Não instalada; NDA real inactivo | Exemplar/versão/aprovação do NDA; tipos jurídicos de comprovativo; GD/WebP; visual |
| 5 — Fecho operacional | Retirada imediata, revalidação/reativação, validade configurável/expiração, denúncias/tarefas/Dashboard, destaques administrativos e compatibilidade | Esta entrega; `PrivateOfferOperationsTest` e recibo QA | Sem instalação ou activação comercial/pública | Apreciação desta entrega; prazo/política final por definir; pendências das fases anteriores preservadas |
Esta matriz integra o relatório solicitado e actualiza RF-MN-003 e o manual existentes; não é um novo inventário geral. O conjunto de Ofertas fica preparado em QA, mas não é declarado operacional em público, instalado, juridicamente fechado ou visualmente aceite. Procuras e o restante portal não são encerrados por esta entrega.
## Ficheiros alterados
| Grupo | Finalidade |
| --- | --- |
| `app/Services/OfferAvailability.php`, `PrivateOfferOperations.php` | Disponibilidade/validade, operações do dono, denúncias, tarefas, auditoria e planos administrativos |
| `OpportunityProfileWorkflow.php`, `PrivateOfferPreparation.php`, `PrivateOfferConfidentiality.php` | Ciclo operacional no snapshot, reabertura/revalidação existentes e exclusão da leitura NDA |
| `OfferPublicCatalogue.php`, `OfferPublicCatalogueController.php` | Exclusão consistente, contrato anterior preservado, validade opcional e cache HTTP |
| `PrivateOfferOperationsController.php`, `PrivateOfferPreparationController.php`, `PortalEditorialWorkflowController.php`, `routes/web.php` | Endpoints privados, filas no âmbito e entrada no Dashboard existente |
| `app/Console/Commands/ExpirePrivateOffers.php`, `routes/console.php`, `config/private_offers.php` | Conferência/materialização da expiração; prazo e agendamento sem activação por defeito |
| `resources/js/components/offer-operations.tsx`, `public-offer-card.tsx`, `lib/public-offer.ts` | Acompanhamento privado e data pública opcional |
| Páginas `offers/report`, `offers/preparation`, `offers/preparation-index`, `offers/public-detail`, `management/editorial-workflow` | Denúncia, retirada/reativação, tratamento e preparação administrativa integrados |
| `tests/Feature/PrivateOfferOperationsTest.php` | Testes novos de segurança, percursos e compatibilidade |
| Matriz RF-MN-003, manual, este relatório e recibo QA | Saldo e evidência das cinco fases |
## Limitações e decisões pendentes
1. **NDA:** a Tempo confirmou que ainda falta o exemplar/versão e a referência da aprovação. `private_offers.nda` mantém-se nulo. Não foi inventado texto jurídico ou uma assinatura com validade presumida.
2. **Imóveis:** os tipos jurídicos de comprovativo admissíveis continuam por identificar. Upload/scan/revisão interna não certificam titularidade, licenciamento ou disponibilidade do bem.
3. **Validade e divulgação:** a Tempo ainda não definiu prazo definitivo. A configuração permanece nula, sem presumir uma duração. Avisos jurídicos e eventuais condições comerciais de uma publicação futura não foram fabricados.
4. **Visual:** a conferência desktop/móvel das fases anteriores permanece pendente. A última tentativa registada do conector devolveu `The browser access host is unavailable`. Não foram repetidos reinícios, desactivados controlos ou apresentados HTML/compilação como aceitação visual.
5. **Imagens:** GD/WebP não está disponível neste PHP QA. Os originais privados e as validações da F4 foram demonstrados; geração/inspecção de derivadas e galeria pública não são declaradas concluídas. Não foram instaladas extensões.
6. **Email:** a entrega real da F2 continua pendente. Não foram activados novos transportes, SMTP, SMS ou destinatários operacionais.
7. **Qualidade preexistente:** a formatação global mantém os mesmos 61 ficheiros fora do delta. Lint focado, tipos e análise estática são registados separadamente dessa falha anterior.
Próximo passo: apreciação desta entrega QA e resolução das dependências documentais/visuais indicadas. **Qualquer instalação, configuração activa ou publicação aguarda autorização expressa da Tempo.**
@@ -0,0 +1,332 @@
> build
> vp build
npm.cmd : [plugin laravel:fonts] Optimized font fallbacks require the optional "fontaine" package. Install it, or set
"optimizedFallbacks: false" on your fonts to disable the feature.
At line:2 char:30
+ ... _BUILD='1'; npm.cmd run build *> outputs/offers-private-phase5-202611 ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: ([plugin laravel...le the feature.:String) [], RemoteException
+ FullyQualifiedErrorId : NativeCommandError
transforming...
✓ 2847 modules transformed.
rendering chunks...
computing gzip size...
public/build-qa-opportunities/assets/layers-BWBAp2CZ.png 0.69 kB
public/build-qa-opportunities/assets/layers-2x-Bpkbi35X.png 1.25 kB
public/build-qa-opportunities/assets/marker-icon-hN30_KVU.png 1.46 kB
public/build-qa-opportunities/assets/noto-sans-400-normal-BTkUljjl.woff2 13.12 kB
public/build-qa-opportunities/assets/noto-sans-700-normal-CrvTEWhy.woff2 13.46 kB
public/build-qa-opportunities/assets/noto-sans-500-normal-DCA2Z9Cj.woff2 13.49 kB
public/build-qa-opportunities/assets/noto-sans-600-normal-Dkgu5AE5.woff2 13.49 kB
public/build-qa-opportunities/assets/noto-serif-400-normal-BV_iYGc9.woff2 14.40 kB
public/build-qa-opportunities/assets/noto-serif-700-normal-CIw17UNa.woff2 15.23 kB
public/build-qa-opportunities/assets/noto-serif-600-normal-BiuDA9L7.woff2 15.31 kB
public/build-qa-opportunities/assets/noto-sans-400-normal-DPpKfCgK.woff 16.68 kB
public/build-qa-opportunities/assets/instrument-sans-400-normal-DRC__1Mx.woff2 16.86 kB
public/build-qa-opportunities/assets/noto-sans-700-normal-ZyrKABVF.woff 16.95 kB
public/build-qa-opportunities/assets/noto-sans-500-normal-CDPngY5C.woff 17.09 kB
public/build-qa-opportunities/assets/noto-sans-600-normal-DVJMj_t0.woff 17.10 kB
public/build-qa-opportunities/assets/instrument-sans-500-normal-Dk9ku72i.woff2 17.23 kB
public/build-qa-opportunities/assets/instrument-sans-600-normal-B7fBEWYG.woff2 17.40 kB
public/build-qa-opportunities/assets/noto-serif-400-normal-DT0cwSa_.woff 17.81 kB
public/build-qa-opportunities/fonts-manifest.json 18.53 kB │ gzip: 1.23 kB
public/build-qa-opportunities/assets/noto-serif-700-normal-mX-AlFMT.woff 18.54 kB
public/build-qa-opportunities/assets/noto-serif-600-normal-RZopCp-J.woff 18.68 kB
public/build-qa-opportunities/assets/instrument-sans-400-normal-D1W7dsQl.woff 21.24 kB
public/build-qa-opportunities/assets/instrument-sans-500-normal-Z6ESRlEs.woff 21.65 kB
public/build-qa-opportunities/assets/instrument-sans-600-normal-B9e8oLYv.woff 21.67 kB
public/build-qa-opportunities/manifest.json 114.60 kB │ gzip: 11.96 kB
public/build-qa-opportunities/assets/fonts-CcFLQaQ-.css 7.91 kB │ gzip: 0.65 kB
public/build-qa-opportunities/assets/investment-catalogue-WaRR19FV.css 8.15 kB │ gzip: 2.00 kB
public/build-qa-opportunities/assets/interactive-investment-map-Bm3WjoQm.css 10.62 kB │ gzip: 2.65 kB
public/build-qa-opportunities/assets/app-UznX-FqS.css 157.42 kB │ gzip: 25.28 kB
public/build-qa-opportunities/assets/input-text-BlEFHnL-.js 0.09 kB │ gzip: 0.10 kB
public/build-qa-opportunities/assets/check-PcBNn_NG.js 0.12 kB │ gzip: 0.14 kB
public/build-qa-opportunities/assets/company-comparison-qLTgpXjz.js 0.12 kB │ gzip: 0.13 kB
public/build-qa-opportunities/assets/display-value-DRR1EAAK.js 0.13 kB │ gzip: 0.12 kB
public/build-qa-opportunities/assets/value-text-BztaYdGl.js 0.14 kB │ gzip: 0.12 kB
public/build-qa-opportunities/assets/loader-circle-DS8Tf4er.js 0.14 kB │ gzip: 0.15 kB
public/build-qa-opportunities/assets/shared-B-QpuOU2.js 0.15 kB │ gzip: 0.13 kB
public/build-qa-opportunities/assets/arrow-left-DMVHIw8Z.js 0.16 kB │ gzip: 0.16 kB
public/build-qa-opportunities/assets/search-BRvPeo47.js 0.17 kB │ gzip: 0.16 kB
public/build-qa-opportunities/assets/circle-check-DckzuBoW.js 0.17 kB │ gzip: 0.16 kB
public/build-qa-opportunities/assets/product-usage-CHhCL93_.js 0.18 kB │ gzip: 0.17 kB
public/build-qa-opportunities/assets/editorial-states-Bc7NQzsj.js 0.19 kB │ gzip: 0.16 kB
public/build-qa-opportunities/assets/types-BoD_IJFv.js 0.19 kB │ gzip: 0.16 kB
public/build-qa-opportunities/assets/participation-identity-BWabb4yN.js 0.24 kB │ gzip: 0.18 kB
public/build-qa-opportunities/assets/circle-alert-uV7omYjr.js 0.24 kB │ gzip: 0.18 kB
public/build-qa-opportunities/assets/eye-CmcEBfxN.js 0.25 kB │ gzip: 0.20 kB
public/build-qa-opportunities/assets/log-in-Wgc8VLfq.js 0.25 kB │ gzip: 0.22 kB
public/build-qa-opportunities/assets/partnership-history-CfclSLAo.js 0.27 kB │ gzip: 0.19 kB
public/build-qa-opportunities/assets/compiler-runtime-sKsOXjDo.js 0.27 kB │ gzip: 0.23 kB
public/build-qa-opportunities/assets/file-text-CEJtuNfp.js 0.33 kB │ gzip: 0.21 kB
public/build-qa-opportunities/assets/confirm-Culw9Q-x.js 0.36 kB │ gzip: 0.22 kB
public/build-qa-opportunities/assets/text-link-BBvcP60s.js 0.40 kB │ gzip: 0.27 kB
public/build-qa-opportunities/assets/content-management-entry-C_oY4VyW.js 0.42 kB │ gzip: 0.31 kB
public/build-qa-opportunities/assets/handshake-YqyTTm3a.js 0.44 kB │ gzip: 0.28 kB
public/build-qa-opportunities/assets/offer-editorial-BOYEEQS3.js 0.45 kB │ gzip: 0.27 kB
public/build-qa-opportunities/assets/investment-guides-DO58L8zr.js 0.45 kB │ gzip: 0.26 kB
public/build-qa-opportunities/assets/catalogue-context-epuehaJf.js 0.47 kB │ gzip: 0.30 kB
public/build-qa-opportunities/assets/operation-key-Dr4VtGxa.js 0.51 kB │ gzip: 0.35 kB
public/build-qa-opportunities/assets/spinner-Dmx1j41V.js 0.52 kB │ gzip: 0.36 kB
public/build-qa-opportunities/assets/types-Cxp_Nskt.js 0.55 kB │ gzip: 0.32 kB
public/build-qa-opportunities/assets/sliders-horizontal-D-4FqAHA.js 0.58 kB │ gzip: 0.26 kB
public/build-qa-opportunities/assets/page-preparation-guard-B-OckhBt.js 0.59 kB │ gzip: 0.35 kB
public/build-qa-opportunities/assets/portal-presentation-fH7FShU8.js 0.62 kB │ gzip: 0.38 kB
public/build-qa-opportunities/assets/confidentiality-C2eqbRBb.js 0.64 kB │ gzip: 0.41 kB
public/build-qa-opportunities/assets/dist-qw3FPjRO.js 0.74 kB │ gzip: 0.49 kB
public/build-qa-opportunities/assets/labels-DxqjesIG.js 0.74 kB │ gzip: 0.46 kB
public/build-qa-opportunities/assets/wayfinder-Dp7PqxvV.js 0.89 kB │ gzip: 0.53 kB
public/build-qa-opportunities/assets/createLucideIcon-oMeAlXbs.js 0.95 kB │ gzip: 0.56 kB
public/build-qa-opportunities/assets/use-interest-draft-waHUBAew.js 0.99 kB │ gzip: 0.51 kB
public/build-qa-opportunities/assets/context-documents-Bh7vrWBo.js 1.01 kB │ gzip: 0.57 kB
public/build-qa-opportunities/assets/label-Dpfg_IeY.js 1.02 kB │ gzip: 0.62 kB
public/build-qa-opportunities/assets/technical-incident-labels-DlL6bSeE.js 1.02 kB │ gzip: 0.51 kB
public/build-qa-opportunities/assets/investment-consumer-identity-Cxp_hGfX.js 1.07 kB │ gzip: 0.59 kB
public/build-qa-opportunities/assets/article-C5PlVvrU.js 1.08 kB │ gzip: 0.65 kB
public/build-qa-opportunities/assets/search-state-RHLy6yI7.js 1.10 kB │ gzip: 0.68 kB
public/build-qa-opportunities/assets/rolldown-runtime-Dd_uD5pT.js 1.10 kB │ gzip: 0.62 kB
public/build-qa-opportunities/assets/catalogue-investment-map-BMbmCrqR.js 1.12 kB │ gzip: 0.62 kB
public/build-qa-opportunities/assets/investment-contexts-BQGiwu9n.js 1.17 kB │ gzip: 0.60 kB
public/build-qa-opportunities/assets/profile-json-Bza-VQzw.js 1.20 kB │ gzip: 0.72 kB
public/build-qa-opportunities/assets/document-T3QM1nf4.js 1.23 kB │ gzip: 0.72 kB
public/build-qa-opportunities/assets/shared-JTxA6RNb.js 1.24 kB │ gzip: 0.74 kB
public/build-qa-opportunities/assets/public-detail-TGFEaj8I.js 1.25 kB │ gzip: 0.72 kB
public/build-qa-opportunities/assets/demographic-context-BMdtlxjJ.js 1.27 kB │ gzip: 0.73 kB
public/build-qa-opportunities/assets/public-offer-card-Dhz4h4ig.js 1.30 kB │ gzip: 0.70 kB
public/build-qa-opportunities/assets/input-SCOe2C97.js 1.36 kB │ gzip: 0.75 kB
public/build-qa-opportunities/assets/cluster-competitiveness-hNV0FaaW.js 1.37 kB │ gzip: 0.73 kB
public/build-qa-opportunities/assets/verify-email-CBwdNKm0.js 1.40 kB │ gzip: 0.74 kB
public/build-qa-opportunities/assets/business-types-SGCXhH6X.js 1.47 kB │ gzip: 0.75 kB
public/build-qa-opportunities/assets/verification-BOcn3HIw.js 1.48 kB │ gzip: 0.52 kB
public/build-qa-opportunities/assets/country-select-4uP5BvMf.js 1.49 kB │ gzip: 0.78 kB
public/build-qa-opportunities/assets/territorial-source-evidence-eKB4ZB36.js 1.53 kB │ gzip: 0.83 kB
public/build-qa-opportunities/assets/manage-DNCBnmgf.js 1.56 kB │ gzip: 0.74 kB
public/build-qa-opportunities/assets/educational-article-card-0rCqZ1kX.js 1.59 kB │ gzip: 0.72 kB
public/build-qa-opportunities/assets/partnership-source-links-VfrXk2FZ.js 1.65 kB │ gzip: 0.91 kB
public/build-qa-opportunities/assets/catalogue-vIlL0AoR.js 1.78 kB │ gzip: 0.96 kB
public/build-qa-opportunities/assets/technical-incident-notice-CjziWA6m.js 1.79 kB │ gzip: 0.89 kB
public/build-qa-opportunities/assets/report-DRxtlCFu.js 1.80 kB │ gzip: 0.93 kB
public/build-qa-opportunities/assets/opportunity-technical-incidents-C4aVbut7.js 1.88 kB │ gzip: 0.98 kB
public/build-qa-opportunities/assets/password-input-BCuBEWkB.js 1.88 kB │ gzip: 1.05 kB
public/build-qa-opportunities/assets/passkey-verify-D57KR0ve.js 2.04 kB │ gzip: 1.15 kB
public/build-qa-opportunities/assets/public-record-offline-BmjvnSLE.js 2.07 kB │ gzip: 1.22 kB
public/build-qa-opportunities/assets/investors-Di6y94fY.js 2.09 kB │ gzip: 1.15 kB
public/build-qa-opportunities/assets/forgot-password-T9DcZJvN.js 2.10 kB │ gzip: 0.98 kB
public/build-qa-opportunities/assets/sources-C4rNpAhk.js 2.13 kB │ gzip: 1.12 kB
public/build-qa-opportunities/assets/saved-CuZHxZQR.js 2.21 kB │ gzip: 1.10 kB
public/build-qa-opportunities/assets/zone-map-SY-hKDZP.js 2.26 kB │ gzip: 1.16 kB
public/build-qa-opportunities/assets/appearance-Drnhw1ee.js 2.36 kB │ gzip: 1.14 kB
public/build-qa-opportunities/assets/shared-CBJ3c_x-.js 2.37 kB │ gzip: 1.24 kB
public/build-qa-opportunities/assets/editorial-subjects-C6_MfRSK.js 2.38 kB │ gzip: 1.14 kB
public/build-qa-opportunities/assets/context-installation-ixvPSLFr.js 2.39 kB │ gzip: 1.23 kB
public/build-qa-opportunities/assets/workspace-4_R1CLYI.js 2.40 kB │ gzip: 1.20 kB
public/build-qa-opportunities/assets/reset-password-Dm0VnxgR.js 2.43 kB │ gzip: 0.98 kB
public/build-qa-opportunities/assets/public-document-content-BHvs9J7S.js 2.47 kB │ gzip: 0.80 kB
public/build-qa-opportunities/assets/manage-Cdsvzg2y.js 2.47 kB │ gzip: 1.10 kB
public/build-qa-opportunities/assets/confirm-password-BYmSXd2u.js 2.47 kB │ gzip: 1.06 kB
public/build-qa-opportunities/assets/structures-Dr2nXdub.js 2.49 kB │ gzip: 1.35 kB
public/build-qa-opportunities/assets/preparation-index-DEe7dOdy.js 2.53 kB │ gzip: 1.07 kB
public/build-qa-opportunities/assets/bootstrap-confirmation-9RmA5-Yz.js 2.55 kB │ gzip: 1.19 kB
public/build-qa-opportunities/assets/territory-associations-D-P2rzJv.js 2.55 kB │ gzip: 1.11 kB
public/build-qa-opportunities/assets/installation-Dly8Xg6e.js 2.59 kB │ gzip: 1.28 kB
public/build-qa-opportunities/assets/technical-support-DlW3Jv7q.js 2.60 kB │ gzip: 1.18 kB
public/build-qa-opportunities/assets/management-CvdeRZYq.js 2.68 kB │ gzip: 1.18 kB
public/build-qa-opportunities/assets/experiments-_6FE66dI.js 2.73 kB │ gzip: 1.31 kB
public/build-qa-opportunities/assets/password-CB_NxgQN.js 2.73 kB │ gzip: 0.73 kB
public/build-qa-opportunities/assets/opportunity-install-B232n6lM.js 2.75 kB │ gzip: 1.34 kB
public/build-qa-opportunities/assets/dist-BobFCjoG.js 2.92 kB │ gzip: 1.29 kB
public/build-qa-opportunities/assets/portal-api-CpQBRfcu.js 2.98 kB │ gzip: 1.50 kB
public/build-qa-opportunities/assets/two-factor-challenge-C-VleRaI.js 3.24 kB │ gzip: 1.52 kB
public/build-qa-opportunities/assets/territory-geometry-preview-952xh2Er.js 3.25 kB │ gzip: 1.47 kB
public/build-qa-opportunities/assets/workspace-BiFp0p_Q.js 3.29 kB │ gzip: 1.44 kB
public/build-qa-opportunities/assets/partnership-agreement-instrument-CCnuZNBy.js 3.43 kB │ gzip: 1.49 kB
public/build-qa-opportunities/assets/funding-classification-CXvhHOwp.js 3.44 kB │ gzip: 1.64 kB
public/build-qa-opportunities/assets/guide-mndEuKFT.js 3.45 kB │ gzip: 1.43 kB
public/build-qa-opportunities/assets/history-DSPDM8gM.js 3.46 kB │ gzip: 1.70 kB
public/build-qa-opportunities/assets/audit-Xdo8qR4z.js 3.51 kB │ gzip: 1.69 kB
public/build-qa-opportunities/assets/show-D2EX4aTO.js 3.60 kB │ gzip: 1.82 kB
public/build-qa-opportunities/assets/demography-BaQAqCAU.js 3.70 kB │ gzip: 1.79 kB
public/build-qa-opportunities/assets/corridor-agribusiness-BcIVf-hW.js 3.72 kB │ gzip: 1.53 kB
public/build-qa-opportunities/assets/business-opportunity-5M_31Cua.js 3.73 kB │ gzip: 1.63 kB
public/build-qa-opportunities/assets/management-access-D4VE9LUB.js 3.75 kB │ gzip: 1.43 kB
public/build-qa-opportunities/assets/businesses-A6nx0k6S.js 3.76 kB │ gzip: 1.57 kB
public/build-qa-opportunities/assets/editorial-reading-DUFQmJtD.js 3.89 kB │ gzip: 1.68 kB
public/build-qa-opportunities/assets/catalog-B6dpdVsW.js 3.95 kB │ gzip: 1.51 kB
public/build-qa-opportunities/assets/desk-assignments-CfWpiIpK.js 3.99 kB │ gzip: 1.95 kB
public/build-qa-opportunities/assets/dashboard-CxoTLkSg.js 3.99 kB │ gzip: 1.61 kB
public/build-qa-opportunities/assets/investment-context-cards-gJe_oyxw.js 4.05 kB │ gzip: 1.84 kB
public/build-qa-opportunities/assets/shared-7qc3eq3D.js 4.09 kB │ gzip: 2.05 kB
public/build-qa-opportunities/assets/opportunity-contact-form-Cb6iDQ1U.js 4.10 kB │ gzip: 1.67 kB
public/build-qa-opportunities/assets/demographic-evidence-DGwOnf5g.js 4.38 kB │ gzip: 1.84 kB
public/build-qa-opportunities/assets/conversation-Dkxk4yT2.js 4.42 kB │ gzip: 1.95 kB
public/build-qa-opportunities/assets/portfolio-Dmt3KDgL.js 4.45 kB │ gzip: 1.74 kB
public/build-qa-opportunities/assets/reference-C9cU7M38.js 4.46 kB │ gzip: 2.00 kB
public/build-qa-opportunities/assets/territory-geometry-import-BnSl5jzG.js 4.49 kB │ gzip: 1.90 kB
public/build-qa-opportunities/assets/opportunity-origin-D3o6OTRg.js 4.64 kB │ gzip: 2.07 kB
public/build-qa-opportunities/assets/context-inheritance-dd0VgP8h.js 4.64 kB │ gzip: 2.00 kB
public/build-qa-opportunities/assets/installation-CnslW5oi.js 4.67 kB │ gzip: 1.95 kB
public/build-qa-opportunities/assets/opportunities-YsMGMCR_.js 4.73 kB │ gzip: 1.77 kB
public/build-qa-opportunities/assets/references-Cu0zjt4N.js 4.79 kB │ gzip: 2.22 kB
public/build-qa-opportunities/assets/offer-confidential-access-Bu9EgZ3d.js 4.83 kB │ gzip: 1.91 kB
public/build-qa-opportunities/assets/opportunity-curation-7Gt2cWer.js 4.88 kB │ gzip: 2.04 kB
public/build-qa-opportunities/assets/business-verifications-D6fOYDwf.js 4.90 kB │ gzip: 1.71 kB
public/build-qa-opportunities/assets/crm-edit-comparison-Db53T7I7.js 4.91 kB │ gzip: 1.99 kB
public/build-qa-opportunities/assets/investment-complements-CXfMOoGq.js 5.00 kB │ gzip: 2.05 kB
public/build-qa-opportunities/assets/agreement-history-BfSa8E7Q.js 5.13 kB │ gzip: 2.10 kB
public/build-qa-opportunities/assets/catalogue-D8gbcfdu.js 5.13 kB │ gzip: 2.54 kB
public/build-qa-opportunities/assets/workspace-BeyUwWtD.js 5.20 kB │ gzip: 2.18 kB
public/build-qa-opportunities/assets/article-editor-ByLxbPbJ.js 5.25 kB │ gzip: 2.20 kB
public/build-qa-opportunities/assets/context-start-B2aU0zYo.js 5.46 kB │ gzip: 2.10 kB
public/build-qa-opportunities/assets/economic-references-C7dNVUC6.js 5.61 kB │ gzip: 2.52 kB
public/build-qa-opportunities/assets/convert-B3LtI2n6.js 5.81 kB │ gzip: 2.40 kB
public/build-qa-opportunities/assets/editorial-subject-form-B2W8E2NJ.js 5.87 kB │ gzip: 2.66 kB
public/build-qa-opportunities/assets/public-sector-cards-Esb1aTm6.js 5.94 kB │ gzip: 2.55 kB
public/build-qa-opportunities/assets/demography-install-B57rikBv.js 6.05 kB │ gzip: 2.50 kB
public/build-qa-opportunities/assets/context-C_aFYdX-.js 6.06 kB │ gzip: 2.65 kB
public/build-qa-opportunities/assets/request-YZw1-_QG.js 6.10 kB │ gzip: 2.41 kB
public/build-qa-opportunities/assets/structure-geography-ELCgr0dd.js 6.12 kB │ gzip: 2.22 kB
public/build-qa-opportunities/assets/editorial-workflow-DYYZzXOw.js 6.22 kB │ gzip: 2.29 kB
public/build-qa-opportunities/assets/investment-map-panel-BgicrnXE.js 6.27 kB │ gzip: 2.88 kB
public/build-qa-opportunities/assets/procurement-HZSwnYGC.js 6.35 kB │ gzip: 2.73 kB
public/build-qa-opportunities/assets/register-vy51_8_H.js 6.39 kB │ gzip: 2.70 kB
public/build-qa-opportunities/assets/funding-Dp_3_Uu2.js 6.49 kB │ gzip: 2.00 kB
public/build-qa-opportunities/assets/leads-Bu2UK8ON.js 6.56 kB │ gzip: 2.82 kB
public/build-qa-opportunities/assets/business-marketplace-C4h1dExR.js 6.59 kB │ gzip: 2.51 kB
public/build-qa-opportunities/assets/interest-xqtiIFPC.js 6.63 kB │ gzip: 2.23 kB
public/build-qa-opportunities/assets/demography-BxaBdOBx.js 6.66 kB │ gzip: 2.51 kB
public/build-qa-opportunities/assets/fiscal-qa-review-CEzw2OQP.js 6.75 kB │ gzip: 2.70 kB
public/build-qa-opportunities/assets/business-interest-B01b-tRY.js 6.82 kB │ gzip: 2.60 kB
public/build-qa-opportunities/assets/interests-BcpPowrJ.js 6.83 kB │ gzip: 2.78 kB
public/build-qa-opportunities/assets/catalogue-CoNk3Fdv.js 6.84 kB │ gzip: 2.81 kB
public/build-qa-opportunities/assets/company-comparison-8kehiDPm.js 6.92 kB │ gzip: 2.60 kB
public/build-qa-opportunities/assets/business-classification-C8hVj93v.js 7.01 kB │ gzip: 2.57 kB
public/build-qa-opportunities/assets/dist-Ddn_hEUd.js 7.02 kB │ gzip: 2.71 kB
public/build-qa-opportunities/assets/blank-2jFZEe2q.js 7.07 kB │ gzip: 2.77 kB
public/build-qa-opportunities/assets/analysis-fj0joeC4.js 7.11 kB │ gzip: 2.96 kB
public/build-qa-opportunities/assets/profile-CidlqkJE.js 7.19 kB │ gzip: 2.47 kB
public/build-qa-opportunities/assets/install-BUqVTelf.js 7.23 kB │ gzip: 3.03 kB
public/build-qa-opportunities/assets/investor-COYuuRY_.js 7.30 kB │ gzip: 2.89 kB
public/build-qa-opportunities/assets/investor-path-CSExMjAt.js 7.36 kB │ gzip: 3.02 kB
public/build-qa-opportunities/assets/proactive-supply-review-Bspf5LP7.js 7.55 kB │ gzip: 2.73 kB
public/build-qa-opportunities/assets/legacy-Cgz97_0u.js 7.67 kB │ gzip: 2.56 kB
public/build-qa-opportunities/assets/jsx-runtime-BpzPEenQ.js 7.89 kB │ gzip: 2.99 kB
public/build-qa-opportunities/assets/shared-BFCbvJo7.js 7.90 kB │ gzip: 3.08 kB
public/build-qa-opportunities/assets/business-objects-Uo9gKzR4.js 7.95 kB │ gzip: 2.57 kB
public/build-qa-opportunities/assets/assisted-tUytMJpX.js 8.06 kB │ gzip: 3.16 kB
public/build-qa-opportunities/assets/value-network-Cp8UYtMs.js 8.06 kB │ gzip: 3.08 kB
public/build-qa-opportunities/assets/editor-BpHntkYH.js 8.21 kB │ gzip: 2.97 kB
public/build-qa-opportunities/assets/territorial-editorial-B0ni24WA.js 8.22 kB │ gzip: 3.03 kB
public/build-qa-opportunities/assets/opportunity-promotion-CyWpImc1.js 8.40 kB │ gzip: 2.96 kB
public/build-qa-opportunities/assets/publication-history-DfLO6jOc.js 8.42 kB │ gzip: 3.02 kB
public/build-qa-opportunities/assets/print-Tn3aXHlY.js 8.66 kB │ gzip: 3.55 kB
public/build-qa-opportunities/assets/dialog-C4ibpf_J.js 8.81 kB │ gzip: 2.77 kB
public/build-qa-opportunities/assets/shared-DXIXdF9G.js 8.92 kB │ gzip: 3.83 kB
public/build-qa-opportunities/assets/iva-modelo-a-CNylSdBc.js 8.94 kB │ gzip: 3.27 kB
public/build-qa-opportunities/assets/zone-relations-BLf96C6y.js 8.95 kB │ gzip: 2.99 kB
public/build-qa-opportunities/assets/investment-territory-MNjWS_u0.js 8.96 kB │ gzip: 3.43 kB
public/build-qa-opportunities/assets/similar-businesses-HTwQGVGQ.js 9.11 kB │ gzip: 3.58 kB
public/build-qa-opportunities/assets/interactive-investment-map-BClK9yWO.js 9.13 kB │ gzip: 3.60 kB
public/build-qa-opportunities/assets/investment-land-preparation-BHeC9B2U.js 9.15 kB │ gzip: 3.33 kB
public/build-qa-opportunities/assets/why-invest-Cwz2gK0p.js 9.16 kB │ gzip: 3.49 kB
public/build-qa-opportunities/assets/interest-history-oHNykKmC.js 9.48 kB │ gzip: 3.45 kB
public/build-qa-opportunities/assets/content-hub-CzsGgtNo.js 9.95 kB │ gzip: 2.93 kB
public/build-qa-opportunities/assets/privacy-policy-LI1lpUCK.js 10.36 kB │ gzip: 3.07 kB
public/build-qa-opportunities/assets/editorial-edition-Zq39X7mf.js 10.36 kB │ gzip: 3.47 kB
public/build-qa-opportunities/assets/panel-D5bg3ajo.js 10.46 kB │ gzip: 4.29 kB
public/build-qa-opportunities/assets/result-yez5wOFo.js 10.57 kB │ gzip: 4.73 kB
public/build-qa-opportunities/assets/dashboard-C0NQF0VJ.js 10.92 kB │ gzip: 3.47 kB
public/build-qa-opportunities/assets/zone-attachments-CLqpntOa.js 10.94 kB │ gzip: 2.75 kB
public/build-qa-opportunities/assets/business-D-GsDwzk.js 11.10 kB │ gzip: 3.09 kB
public/build-qa-opportunities/assets/detail-3fwgDkH3.js 11.73 kB │ gzip: 4.30 kB
public/build-qa-opportunities/assets/login-CHsqdM2X.js 11.82 kB │ gzip: 4.90 kB
public/build-qa-opportunities/assets/support-installation-tsBpnQkP.js 12.36 kB │ gzip: 4.86 kB
public/build-qa-opportunities/assets/show-HFfMuLZ-.js 12.37 kB │ gzip: 3.98 kB
public/build-qa-opportunities/assets/show-D9Qj4eSh.js 12.41 kB │ gzip: 4.39 kB
public/build-qa-opportunities/assets/guide-eWGplRw4.js 12.58 kB │ gzip: 4.03 kB
public/build-qa-opportunities/assets/contact-CrhBB858.js 12.65 kB │ gzip: 4.33 kB
public/build-qa-opportunities/assets/project-dossier-document-CiCplPXo.js 12.68 kB │ gzip: 5.20 kB
public/build-qa-opportunities/assets/dossier-prefill-B90PO41l.js 12.72 kB │ gzip: 4.65 kB
public/build-qa-opportunities/assets/technical-incident-GDB_x0wp.js 12.73 kB │ gzip: 4.96 kB
public/build-qa-opportunities/assets/competitiveness-60nqrOfg.js 12.74 kB │ gzip: 4.23 kB
public/build-qa-opportunities/assets/editor-t_I3E9vl.js 12.91 kB │ gzip: 3.75 kB
public/build-qa-opportunities/assets/fiscal-forms-CCePiKSA.js 13.04 kB │ gzip: 4.75 kB
public/build-qa-opportunities/assets/opportunity-classification-Dm_W66ep.js 13.44 kB │ gzip: 4.55 kB
public/build-qa-opportunities/assets/react-CjVep8xp.js 13.90 kB │ gzip: 4.61 kB
public/build-qa-opportunities/assets/opportunity-search-filters-B1kHkt50.js 14.73 kB │ gzip: 4.72 kB
public/build-qa-opportunities/assets/case-wwTeoOmv.js 14.77 kB │ gzip: 5.38 kB
public/build-qa-opportunities/assets/product-usage-Csn5TQSM.js 14.81 kB │ gzip: 5.30 kB
public/build-qa-opportunities/assets/company-preparation-8z1Qvdvd.js 15.17 kB │ gzip: 4.92 kB
public/build-qa-opportunities/assets/terms-of-use-CUQdwKes.js 15.27 kB │ gzip: 4.20 kB
public/build-qa-opportunities/assets/edit-qyf8y1Qe.js 15.38 kB │ gzip: 5.15 kB
public/build-qa-opportunities/assets/relationship-repair-brZoqSge.js 16.29 kB │ gzip: 5.62 kB
public/build-qa-opportunities/assets/edit-CWwLVVG7.js 16.60 kB │ gzip: 4.88 kB
public/build-qa-opportunities/assets/edit-DeIwdeuK.js 16.93 kB │ gzip: 4.91 kB
public/build-qa-opportunities/assets/search-hMTBVa-Z.js 17.08 kB │ gzip: 5.65 kB
public/build-qa-opportunities/assets/funding-institution-DQq7N6kA.js 17.24 kB │ gzip: 5.83 kB
public/build-qa-opportunities/assets/use-two-factor-auth-D9w0KtSq.js 17.31 kB │ gzip: 6.30 kB
public/build-qa-opportunities/assets/administration-CgUGyaX7.js 17.41 kB │ gzip: 4.30 kB
public/build-qa-opportunities/assets/notifications-COYs8HfG.js 17.61 kB │ gzip: 5.83 kB
public/build-qa-opportunities/assets/ai-CNDnFHJf.js 18.08 kB │ gzip: 6.92 kB
public/build-qa-opportunities/assets/project-dossier-report-BPxivfbL.js 18.98 kB │ gzip: 6.95 kB
public/build-qa-opportunities/assets/submission-DYV8rsdX.js 19.98 kB │ gzip: 6.32 kB
public/build-qa-opportunities/assets/opportunity-resources-B8WM1sIO.js 20.19 kB │ gzip: 7.22 kB
public/build-qa-opportunities/assets/about-D8OtIyBz.js 20.87 kB │ gzip: 6.99 kB
public/build-qa-opportunities/assets/zones-CAkKqhGW.js 21.16 kB │ gzip: 5.22 kB
public/build-qa-opportunities/assets/service-editor-Bu5x27nt.js 21.30 kB │ gzip: 6.58 kB
public/build-qa-opportunities/assets/context-D8SLahjT.js 21.54 kB │ gzip: 6.99 kB
public/build-qa-opportunities/assets/declaration-Bzl3lixx.js 22.15 kB │ gzip: 7.14 kB
public/build-qa-opportunities/assets/Combination-BBDI-RbM.js 23.03 kB │ gzip: 8.24 kB
public/build-qa-opportunities/assets/desk-internal-operations-BwHIs_KM.js 23.14 kB │ gzip: 6.53 kB
public/build-qa-opportunities/assets/education-CFNgleBU.js 23.40 kB │ gzip: 8.19 kB
public/build-qa-opportunities/assets/investment-opportunity-choices-D56R3GRo.js 24.28 kB │ gzip: 8.06 kB
public/build-qa-opportunities/assets/preparation-rSUWHMu_.js 24.56 kB │ gzip: 6.73 kB
public/build-qa-opportunities/assets/utils-CCDJ05Ll.js 27.31 kB │ gzip: 8.71 kB
public/build-qa-opportunities/assets/business-wizard-BQMAm94O.js 28.60 kB │ gzip: 9.42 kB
public/build-qa-opportunities/assets/proactive-investment-DfIQuXvs.js 28.65 kB │ gzip: 9.16 kB
public/build-qa-opportunities/assets/lead-vUXgd2g8.js 29.03 kB │ gzip: 9.96 kB
public/build-qa-opportunities/assets/preparation-Ck3nokWs.js 29.32 kB │ gzip: 8.51 kB
public/build-qa-opportunities/assets/form-ClhCCJAo.js 29.80 kB │ gzip: 8.04 kB
public/build-qa-opportunities/assets/profile-Chg149hk.js 30.71 kB │ gzip: 9.33 kB
public/build-qa-opportunities/assets/welcome-bviWRO1E.js 30.73 kB │ gzip: 6.49 kB
public/build-qa-opportunities/assets/manage-GV_WvYCX.js 31.17 kB │ gzip: 7.63 kB
public/build-qa-opportunities/assets/security-DsABJZBj.js 32.11 kB │ gzip: 10.06 kB
public/build-qa-opportunities/assets/editor-CWbYf7YK.js 35.03 kB │ gzip: 11.76 kB
public/build-qa-opportunities/assets/case-Kr0q2Gh8.js 37.38 kB │ gzip: 10.33 kB
public/build-qa-opportunities/assets/room-Bg8k9V54.js 38.25 kB │ gzip: 12.14 kB
public/build-qa-opportunities/assets/simulation-form-CVV000gF.js 39.43 kB │ gzip: 10.27 kB
public/build-qa-opportunities/assets/request-D6rDWCh-.js 41.86 kB │ gzip: 11.29 kB
public/build-qa-opportunities/assets/detail-D8tQRv3B.js 46.42 kB │ gzip: 15.18 kB
public/build-qa-opportunities/assets/PortalInvestidor-C7BfhVCR.js 48.72 kB │ gzip: 14.86 kB
public/build-qa-opportunities/assets/business-financial-plan-s1uoiktr.js 49.19 kB │ gzip: 11.98 kB
public/build-qa-opportunities/assets/catalogue-DqISkm8V.js 49.97 kB │ gzip: 16.08 kB
public/build-qa-opportunities/assets/zone-DEQbIXLp.js 62.43 kB │ gzip: 15.63 kB
public/build-qa-opportunities/assets/show-vTvBmoW9.js 68.49 kB │ gzip: 21.16 kB
public/build-qa-opportunities/assets/opportunity-draft-C7XuiI1O.js 70.65 kB │ gzip: 20.89 kB
public/build-qa-opportunities/assets/document-zip-bqFSTgG4.js 95.97 kB │ gzip: 28.50 kB
public/build-qa-opportunities/assets/leaflet-src-CUWrUTR2.js 148.82 kB │ gzip: 43.39 kB
public/build-qa-opportunities/assets/app-Bez7gBWz.js 221.29 kB │ gzip: 71.24 kB
public/build-qa-opportunities/assets/dist-BRfsww6z.js 307.99 kB │ gzip: 96.67 kB
public/build-qa-opportunities/assets/document-word-CqTGSyXu.js 403.66 kB │ gzip: 115.44 kB
public/build-qa-opportunities/assets/services-DR6lbhJH.js 965.92 kB │ gzip: 226.48 kB
public/build-qa-opportunities/assets/project-dossier-a3IvmE5B.js 1,009.94 kB │ gzip: 274.44 kB
✓ built in 45.51s
[plugin builtin:vite-reporter]
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rolldownOptions.output.codeSplitting to improve chunking:
https://rolldown.rs/reference/OutputOptions.codeSplitting
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
[PLUGIN_TIMINGS] Your build spent 99% of 45.5s inside plugin hooks (44.9s).
Measured inside the callback, so queue time is excluded and time the callback itself awaited is not:
- @rolldown/plugin-babel transform (86%, 39.2s, 411 calls)
- @tailwindcss/vite:generate:build transform (10%, 4.6s, 3 calls)
Those rows are 96% of the build; the rest of the 99% is below.
Not measurable — 1 hook whose calls overlap, so elapsed time covers work other calls were doing. Profile with `node
--cpu-prof`:
- vite:css transform (3 calls)
See https://rolldown.rs/reference/InputOptions.checks#plugintimings for more details.
@@ -0,0 +1 @@
{"tool":"phpunit","result":"passed","tests":12,"passed":12,"assertions":151,"duration_ms":153336}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="Tests\Feature\PrivateOfferOperationsTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" tests="12" assertions="151" errors="0" failures="0" skipped="0" time="153.326411">
<testcase name="test_withdrawal_is_immediate_with_a_stale_form_and_excludes_every_public_consumer" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="103" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="16" time="10.514394"/>
<testcase name="test_reactivation_requires_a_new_exact_validation_and_does_not_resurrect_old_publication" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="121" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="21" time="17.855507"/>
<testcase name="test_withdrawal_during_review_can_be_reopened_by_the_owner_without_erasing_history" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="144" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="7" time="7.678446"/>
<testcase name="test_expiry_is_immediate_at_the_boundary_frozen_and_independent_of_nda_and_scheduler" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="157" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="14" time="7.126520"/>
<testcase name="test_no_deadline_is_invented_when_the_policy_is_pending" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="182" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="7" time="6.666279"/>
<testcase name="test_strangers_reviewers_wrong_scopes_and_auditors_cannot_withdraw_or_reactivate" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="193" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="8" time="6.272797"/>
<testcase name="test_reports_are_private_idempotent_and_treated_in_the_existing_dashboard_and_tasks" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="205" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="21" time="10.469995"/>
<testcase name="test_drafts_cannot_be_reported_by_unrelated_users_and_report_read_requires_current_scope_and_mfa" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="233" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="7" time="5.508392"/>
<testcase name="test_highlights_are_versioned_administrative_only_and_cannot_set_commercial_flags" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="246" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="12" time="9.283486"/>
<testcase name="test_a_withdrawn_first_draft_still_requires_its_first_editorial_validation" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="264" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="10" time="21.656185"/>
<testcase name="test_withdrawal_and_revalidation_do_not_restore_previous_nda_grants" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="276" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="16" time="31.997515"/>
<testcase name="test_an_edited_private_approval_does_not_extend_the_existing_public_deadline" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="305" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="12" time="18.296895"/>
</testsuite>
</testsuites>
@@ -0,0 +1,73 @@
> check
> vp check
resources/js/components/funding-classification.tsx (2ms)
resources/js/components/institutional-source/source-text.tsx (0ms)
resources/js/components/password-input.tsx (1ms)
resources/js/components/sector-entry.tsx (32ms)
resources/js/components/sector-resource-reference.tsx (4ms)
resources/js/components/simulation-form.tsx (109ms)
resources/js/components/simulation-irpc-annual.tsx (9ms)
resources/js/components/structure-geography.tsx (2ms)
resources/js/components/tempo-header-tools.tsx (6ms)
resources/js/lib/investor-path-stage.ts (0ms)
resources/js/lib/irpc-annual-settlement.ts (11ms)
resources/js/pages/funding/assisted.tsx (3ms)
resources/js/pages/funding/manage.tsx (2ms)
resources/js/pages/funding/saved.tsx (2ms)
resources/js/pages/investment/catalogue.tsx (16ms)
resources/js/pages/investment/detail.tsx (4ms)
resources/js/pages/investor-path/index.tsx (3ms)
resources/js/pages/investor-path/preparation.tsx (11ms)
resources/js/pages/investor-path/show.tsx (35ms)
resources/js/pages/management/demography-install.tsx (4ms)
resources/js/pages/portal/fiscal-forms.tsx (8ms)
resources/js/pages/portal/search.tsx (32ms)
resources/js/translation/page-translator.tsx (1ms)
resources/js/translation/service.ts (2ms)
resources/js/translation/types.ts (0ms)
resources/js/translation/utils.ts (3ms)
tests/frontend/cluster-editorial.test.mjs (0ms)
tests/frontend/dashboard-navigation-sections.test.mjs (0ms)
tests/frontend/editorial-completion.test.mjs (0ms)
tests/frontend/editorial-dashboard-manifest.test.mjs (0ms)
tests/frontend/editorial-dashboard-presentation.test.mjs (0ms)
tests/frontend/editorial-edition.test.mjs (0ms)
tests/frontend/education-review.test.mjs (1ms)
tests/frontend/fiscal-form-entry.test.mjs (0ms)
tests/frontend/funding-directory.test.mjs (1ms)
tests/frontend/global-search.test.mjs (1ms)
tests/frontend/helpers/irpc-advance-period-case.mjs (0ms)
tests/frontend/helpers/irpc-annual-noncivil-case.mjs (0ms)
tests/frontend/helpers/irpc-investment-combination-case.mjs (0ms)
tests/frontend/investment-map-indicators.test.mjs (6ms)
tests/frontend/investment-preparation.test.mjs (9ms)
tests/frontend/irpc-advance-cashflow-ui.test.mjs (0ms)
tests/frontend/irpc-advance-cashflow.test.mjs (0ms)
tests/frontend/irpc-advance-period-ui.test.mjs (0ms)
tests/frontend/irpc-advance-period.test.mjs (0ms)
tests/frontend/irpc-annual-noncivil-ui.test.mjs (0ms)
tests/frontend/irpc-annual-noncivil.test.mjs (0ms)
tests/frontend/irpc-investment-carry-combination.test.mjs (0ms)
tests/frontend/irpc-pec-correction.test.mjs (11ms)
tests/frontend/irpc-pec-last-amount.test.mjs (0ms)
tests/frontend/irpc-pec-obligation-ui.test.mjs (0ms)
tests/frontend/irpc-pec-obligation.test.mjs (1ms)
tests/frontend/irpc-ppc-obligation-ui.test.mjs (0ms)
tests/frontend/licensing-stage.test.mjs (4ms)
tests/frontend/navigation-sidebar-render.test.mjs (0ms)
tests/frontend/opportunity-curation.test.mjs (0ms)
tests/frontend/opportunity-information.test.mjs (0ms)
tests/frontend/portal-institutional-contact.test.mjs (0ms)
tests/frontend/portal-navigation.test.mjs (1ms)
tests/frontend/portal-six-group-menu.test.mjs (1ms)
tests/frontend/sector-resource-reference.test.mjs (2ms)
Found formatting issues in 61 files (5491ms, 6 threads). Run `vp check --fix` to fix them.
npm.cmd : error: Formatting issues found
At line:2 char:1
+ npm.cmd run check *> outputs/offers-private-phase5-20261114/global-fo ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (error:...ng issues found:String) [], RemoteException
+ FullyQualifiedErrorId : NativeCommandError
@@ -0,0 +1,170 @@
{
"phase": 5,
"base": "e431de8b423a4b64b9875ca7a8627e3e4b3c9c35",
"state": "implemented and technically verified in QA; awaits acceptance; no installation or effective publication",
"scope": "Ofertas only, phases one to five preserved",
"database": {
"host": "127.0.0.1",
"port": 55441,
"name": "offers_phase1_20261114_test",
"data": "synthetic, existing guards and rollback"
},
"verified_runs": {
"focused.log": {
"tool": "phpunit",
"result": "passed",
"tests": 12,
"passed": 12,
"assertions": 151,
"duration_ms": 153336
},
"regression.log": {
"tool": "phpunit",
"result": "passed",
"tests": 59,
"passed": 59,
"assertions": 780,
"duration_ms": 447792
}
},
"integrated_suites": {
"Tests\\Feature\\PrivateOfferOperationsTest": {
"tests": 13,
"assertions": 180,
"errors": 0,
"failures": 0
},
"Tests\\Feature\\PrivateOfferConfidentialityTest": {
"tests": 11,
"assertions": 160,
"errors": 0,
"failures": 0
},
"Tests\\Feature\\OfferPublicCatalogueTest": {
"tests": 10,
"assertions": 133,
"errors": 0,
"failures": 0
},
"Tests\\Feature\\PrivateOfferPreparationTest": {
"tests": 14,
"assertions": 146,
"errors": 0,
"failures": 0
},
"Tests\\Feature\\PrivateOfferEditorialTest": {
"tests": 11,
"assertions": 161,
"errors": 0,
"failures": 0
}
},
"checks": {
"pint": "passed",
"phpstan": "passed, zero errors",
"typescript": "passed",
"frontend_lint_format": "eight affected files passed",
"qa_build": "passed, build-final.log",
"html": "real compiled preparation, report, operations list, editorial dashboard and earlier page cases passed",
"git_diff_check": "passed",
"global_format": {
"failures": 61,
"unchanged_git_content": true,
"outside_delta": true,
"same_baseline_list": true
}
},
"acceptance_matrix": "docs/investmoz/v2/auditoria-portal/ofertas-fase5-fecho-qa-20261114.md",
"matrix_updated": "RF-MN-003 only; all 49 columns and other rows preserved",
"features": {
"withdrawal": "owner, immediate and idempotent, no editorial approval or stale-etag block",
"reactivation": "new operational cycle, exact new editorial validation and owner confirmation; old public snapshot and NDA grants not resurrected",
"validity": "frozen by approval hash, starts at publication UTC, read-time expiry independent of NDA and cron",
"reports": "encrypted grounds and reported source, idempotent receipt, native tasks/dashboard, scoped MFA reviewer, version and etag",
"highlights": "private draft/archive plans only, versioned and audited; is_featured untouched",
"public_consumers": "list/detail/filters/map/API/CSV exclude unavailable or expired snapshots, no-store HTTP; optional expires_at only when known"
},
"default_configuration": {
"nda": null,
"validity_days": null,
"validity_policy_reference": null,
"expiry_scheduler_enabled": false
},
"restrictions": {
"effective_publication_enabled": false,
"installations_performed": false,
"operational_data_modified": false,
"mne_modified": false,
"migrations_added": false,
"payments_billing_or_commercial_promotion_enabled": false,
"unrelated_features_started": false,
"dependencies_installed": false
},
"accepted_commits_preserved": [
"1f2990e4",
"6e302db6",
"91c18573",
"7691cab3",
"e431de8b"
],
"pending": [
"approved real NDA exemplar/version/approval reference",
"legal identification of property evidence types",
"final validity period/policy and legal notices",
"desktop/mobile visual acceptance; last browser connector evidence host unavailable",
"GD/WebP image derivatives, not installed",
"real email delivery from phase two",
"61 pre-existing format failures",
"express authorization for any installation or effective publication"
],
"source_hash_encoding": "SHA256 of UTF-8 with Git LF normalization",
"source_sha256": {
"app/Console/Commands/ExpirePrivateOffers.php": "19c1a80f73eb063c7c9f26872710954f30f13dea82eaeca3e99190d881960ec5",
"app/Http/Controllers/OfferPublicCatalogueController.php": "04034126a52660e10a6e64207a3aa4fb09a9b03aee045383b240d434477f03ca",
"app/Http/Controllers/PortalEditorialWorkflowController.php": "de74af5fb777033eaf67889e2239b3aa24ec56a758db0a42826367c5c79b8f49",
"app/Http/Controllers/PrivateOfferOperationsController.php": "872ef0dbfae74e07c9ca4181cd2594a365dced8df93f3e4904b374a660a1eade",
"app/Http/Controllers/PrivateOfferPreparationController.php": "32415ac61a948a0bd53f440c728b5e99522017d765910101eb1a7df1d8b6d0d7",
"app/Services/OfferAvailability.php": "72f975c8c01c7a3ccd3cc00d372210b3546dc6bbae1c34d91514b341a7968adf",
"app/Services/OfferPublicCatalogue.php": "7eee7ab9864cc29c2de90b071ff2c3bf3cac093d3adde9e1937ded324b2223c6",
"app/Services/OpportunityProfileWorkflow.php": "2b16a309035514967f9e0290f9167433b4979940aa26f18db2afdb24996d5130",
"app/Services/PrivateOfferConfidentiality.php": "17e02925c3cfe26a4853db272b6d3c2acd1095efac826371a517a603eaa429ca",
"app/Services/PrivateOfferOperations.php": "1b6032e96712f9d19befe76029a47464c3bb52e4f018d858dcc6f8e605cf0cdc",
"app/Services/PrivateOfferPreparation.php": "442d7aa1093001630bdfc581fde3b9f16e4b5b76a4e8d028ba4b3da41b1cfe4d",
"config/private_offers.php": "975e94b3362a60efe60b7c3a227b8e7727e1e0ef63a42f3a1a575ed7c4725f0e",
"docs/investmoz/unification/traceability-and-manual.md": "ba9f42c7c45a58900548fbfb452359b90e90cdcd08f864a90f8da24e2bbdb9f5",
"docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv": "ba2008946615c5f10b6285f725fe32076baabb72612d053b6ab3223fe3ddc3ad",
"docs/investmoz/v2/auditoria-portal/ofertas-fase5-fecho-qa-20261114.md": "4a56d21a4a2d212efc81c780351282846e6f4d970db287f5fc4ebddf75a66ff5",
"resources/js/components/offer-operations.tsx": "ce4a32b422c7a8e33dade2002b9565d53b4294c44f051ab06022fa6fcfc5ee4e",
"resources/js/components/public-offer-card.tsx": "ac8f01a1c9d864b5f83c48774afcd95d1d42005bb0212b15bbc3f6f7258c0db9",
"resources/js/lib/public-offer.ts": "8ec6f030328af26d0ce70665ce82a41102fadf892120b14deb4e90f1ae2a8a48",
"resources/js/pages/management/editorial-workflow.tsx": "9758c6a1cfc4018905c8493777917d316e18a649eb404c868e9c87356d6c273c",
"resources/js/pages/offers/preparation-index.tsx": "50a0feb8ab2c6abf4e725e2c280d05f4a0842d2be49e0c33d2a0a2d5313a361f",
"resources/js/pages/offers/preparation.tsx": "bf7c02232ffa13617f5f65b9fe7704e31410bd622e9270cdbeef92fe5cbcf1f3",
"resources/js/pages/offers/public-detail.tsx": "387c8a9a0013920bf74c85853362671d76131849508a9b86fc2391622cb0c647",
"resources/js/pages/offers/report.tsx": "91f9e32e88c82b1af984226ae81ede188fddaff395743905b8ab8ce483a8f859",
"routes/console.php": "fe352a044273a39d58c1b58f591324879553c8a3deadb0c45aa96ada804f8f2a",
"routes/web.php": "993577842b5cc3cc8cf5f6f136cfe544795e7e838d26c28cde7cb204934d9073",
"tests/Feature/PrivateOfferOperationsTest.php": "f6e60f25543e9a9b15a8f06e563ad1f7e41c31d69b6801daab899619ff7b98e5"
},
"evidence_sha256": {
"focused.log": "0ee1e92b0d653ff397f493ae55e3dce44b99213dcf3b9d27ee22e6e47fca14d7",
"focused.xml": "e397adfb3d556ad39df603b1dd9a0bbd4983095112663406196cebd394bc304c",
"regression.log": "81d4ae963170ae290c0b0a536e1fb4c227db909b4b3dcfc51c2c22b8745389d7",
"regression.xml": "756c8a2dd7d4713e74994f5a27d3acac8e196592eb16d355e8b2fab51ce03218",
"global-format.log": "b9290879ba5de47cb4ad6194125287d3dc6e82bf626ef6f8a03716d453161912",
"build-final.log": "a00480048296be75d57a95658234841bde8db2510aaad908ba00f068e53b3399"
},
"overlapping_runs_not_summed": true,
"report": "docs/investmoz/v2/auditoria-portal/ofertas-fase5-fecho-qa-20261114.md",
"acceptance_matrix_section": "Matriz de aceitação das cinco fases",
"commands": [
"php vendor/bin/phpunit tests/Feature/PrivateOfferOperationsTest.php tests/Feature/PrivateOfferConfidentialityTest.php tests/Feature/OfferPublicCatalogueTest.php tests/Feature/PrivateOfferPreparationTest.php tests/Feature/PrivateOfferEditorialTest.php --log-junit outputs/offers-private-phase5-20261114/regression.xml",
"php vendor/bin/pint --dirty",
"php vendor/bin/phpstan analyse --no-progress --memory-limit=1G",
"npm.cmd run types:check",
"npx.cmd vp check resources/js/components/offer-operations.tsx resources/js/components/public-offer-card.tsx resources/js/lib/public-offer.ts resources/js/pages/offers/report.tsx resources/js/pages/offers/preparation.tsx resources/js/pages/offers/preparation-index.tsx resources/js/pages/offers/public-detail.tsx resources/js/pages/management/editorial-workflow.tsx",
"$env:INVESTMOZ_QA_BUILD='1'; npm.cmd run build",
"npm.cmd run check"
],
"evidence_normalization": "UTF-8, LF; trailing whitespace removed from build/global-format logs without removing messages"
}
@@ -0,0 +1 @@
{"tool":"phpunit","result":"passed","tests":59,"passed":59,"assertions":780,"duration_ms":447792}
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="CLI Arguments" tests="59" assertions="780" errors="0" failures="0" skipped="0" time="447.764764">
<testsuite name="Tests\Feature\PrivateOfferOperationsTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" tests="13" assertions="180" errors="0" failures="0" skipped="0" time="138.776558">
<testcase name="test_withdrawal_is_immediate_with_a_stale_form_and_excludes_every_public_consumer" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="104" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="16" time="11.518284"/>
<testcase name="test_reactivation_requires_a_new_exact_validation_and_does_not_resurrect_old_publication" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="122" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="21" time="17.239731"/>
<testcase name="test_withdrawal_during_review_can_be_reopened_by_the_owner_without_erasing_history" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="145" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="7" time="7.165753"/>
<testcase name="test_expiry_is_immediate_at_the_boundary_frozen_and_independent_of_nda_and_scheduler" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="158" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="16" time="9.718909"/>
<testcase name="test_no_deadline_is_invented_when_the_policy_is_pending" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="184" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="7" time="6.763353"/>
<testcase name="test_strangers_reviewers_wrong_scopes_and_auditors_cannot_withdraw_or_reactivate" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="195" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="8" time="5.962669"/>
<testcase name="test_reports_are_private_idempotent_and_treated_in_the_existing_dashboard_and_tasks" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="207" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="24" time="11.906574"/>
<testcase name="test_drafts_cannot_be_reported_by_unrelated_users_and_report_read_requires_current_scope_and_mfa" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="237" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="7" time="5.972112"/>
<testcase name="test_highlights_are_versioned_administrative_only_and_cannot_set_commercial_flags" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="250" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="12" time="8.137579"/>
<testcase name="test_a_withdrawn_first_draft_still_requires_its_first_editorial_validation" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="268" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="10" time="10.995855"/>
<testcase name="test_withdrawal_and_revalidation_do_not_restore_previous_nda_grants" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="280" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="16" time="22.717109"/>
<testcase name="test_operational_and_report_screens_use_real_compiled_entries_and_private_cache_headers" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="309" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="23" time="6.139717"/>
<testcase name="test_an_edited_private_approval_does_not_extend_the_existing_public_deadline" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="332" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="13" time="14.538913"/>
</testsuite>
<testsuite name="Tests\Feature\PrivateOfferConfidentialityTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" tests="11" assertions="160" errors="0" failures="0" skipped="0" time="127.704001">
<testcase name="test_confirmed_specific_fields_save_recover_and_bind_the_editorial_version" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="107" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="10.781549"/>
<testcase name="test_property_fields_are_optional_and_categories_and_unknown_fields_are_enforced" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="118" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="7" time="7.765091"/>
<testcase name="test_six_photos_limit_includes_restoration_and_alternate_routes_and_cleans_failed_uploads" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="129" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="15" time="17.202170"/>
<testcase name="test_upload_validation_and_quarantine_do_not_grant_publication_or_read_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="171" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="9.385494"/>
<testcase name="test_proof_bytes_are_encrypted_and_only_a_scoped_internal_validator_can_read_them" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="185" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="14" time="5.673129"/>
<testcase name="test_nda_absence_keeps_access_closed_and_acceptance_alone_never_grants_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="207" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="13" time="9.607210"/>
<testcase name="test_both_conditions_exact_versions_revocation_and_audit_are_required_on_every_read" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="225" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="17" time="12.666470"/>
<testcase name="test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="249" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="35" time="26.094163"/>
<testcase name="test_nda_integrity_and_authority_loss_are_rechecked_without_exposing_the_company" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="289" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="14" time="12.227873"/>
<testcase name="test_compiled_confidential_page_has_no_company_data_before_both_conditions" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="313" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="11" time="6.447203"/>
<testcase name="test_photo_metadata_and_original_are_bound_to_the_editorial_fingerprint" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="327" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="16" time="9.853649"/>
</testsuite>
<testsuite name="Tests\Feature\OfferPublicCatalogueTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" tests="10" assertions="133" errors="0" failures="0" skipped="0" time="2.996878">
<testcase name="test_effective_publication_remains_closed_even_with_a_stored_projection" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="51" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="9" time="0.148012"/>
<testcase name="test_pages_api_csv_and_map_only_use_the_explicit_public_projection" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="68" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="35" time="0.285598"/>
<testcase name="test_invalid_unapproved_unpublished_or_sensitive_snapshots_are_not_readable" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="88" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="10" time="0.175294"/>
<testcase name="test_pending_private_edits_do_not_replace_the_previously_approved_projection" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="101" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="6" time="0.235665"/>
<testcase name="test_prices_are_exact_and_currencies_are_never_converted" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="117" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="12" time="0.174916"/>
<testcase name="test_csv_formula_text_is_escaped_and_http_has_no_publication_operation" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="132" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="5" time="0.185468"/>
<testcase name="test_location_category_and_pagination_use_only_the_published_snapshot" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="143" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="23" time="0.403473"/>
<testcase name="test_public_html_resolves_the_real_compiled_pages_without_private_fields" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="188" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="8" time="0.142677"/>
<testcase name="test_existing_catalogue_keeps_its_own_currency_and_price_validation_contract" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="202" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="3" time="0.097111"/>
<testcase name="test_native_combined_filters_and_export_with_self_contained_synthetic_references" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="210" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="22" time="1.148665"/>
</testsuite>
<testsuite name="Tests\Feature\PrivateOfferPreparationTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" tests="14" assertions="146" errors="0" failures="0" skipped="0" time="44.714555">
<testcase name="test_private_creation_recovery_edit_conflict_and_idempotency_use_one_opportunity" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="60" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="24" time="6.364624"/>
<testcase name="test_incomplete_draft_is_saved_but_submission_requires_common_fields_and_authorized_contact" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="78" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="7" time="1.534426"/>
<testcase name="test_submit_is_private_exact_version_confirmed_and_recoverable_without_editorial_actions" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="87" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="17" time="4.763197"/>
<testcase name="test_other_accounts_ungranted_unverified_and_revoked_actors_cannot_operate" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="110" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="18" time="4.144088"/>
<testcase name="test_contact_scope_and_malicious_fields_roll_back_without_parallel_records" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="127" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="9" time="2.597496"/>
<testcase name="test_price_contract_rejects_conversion_modes_precision_and_negotiable_amount" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="142" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="6" time="2.442104"/>
<testcase name="test_no_public_exposure_and_native_routes_cannot_edit_or_publish_offer" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="152" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="15" time="2.421704"/>
<testcase name="test_existing_opportunity_cannot_be_converted_through_offer_adapter" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="175" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="6" time="1.743423"/>
<testcase name="test_existing_contacts_registration_still_requires_fresh_mfa_and_submission_rechecks_contact" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="191" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="10" time="5.879891"/>
<testcase name="test_model_cannot_publish_or_remove_private_phase_marker" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="210" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="4" time="1.319703"/>
<testcase name="test_location_uses_existing_hierarchy_and_invalid_selection_never_saves" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="225" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="6" time="3.251568"/>
<testcase name="test_full_html_uses_real_compiled_private_page_entries" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="241" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="10" time="2.051605"/>
<testcase name="test_organization_representation_is_rechecked_and_other_organizations_are_not_assignable" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="255" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="5" time="2.829685"/>
<testcase name="test_incomplete_price_is_preserved_but_cannot_be_submitted_and_invalid_structures_return_validation_errors" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="270" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="9" time="3.371040"/>
</testsuite>
<testsuite name="Tests\Feature\PrivateOfferEditorialTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" tests="11" assertions="161" errors="0" failures="0" skipped="0" time="133.572771">
<testcase name="test_submitted_offer_enters_existing_dashboard_and_private_approval_has_exact_snapshot_and_audit" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="83" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="31" time="8.185130"/>
<testcase name="test_rejection_revision_correction_resubmission_preserve_identity_decisions_and_versions" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="113" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="27" time="23.071129"/>
<testcase name="test_return_requires_reason_and_common_field_and_preserves_fields_on_conflict" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="151" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="11" time="16.870455"/>
<testcase name="test_author_contributor_wrong_scope_auditor_and_unassigned_reviewer_cannot_approve" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="170" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="14" time="10.034973"/>
<testcase name="test_reference_changes_block_approval_and_signal_reconfirmation_without_overwriting_decision" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="197" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="12" time="18.436615"/>
<testcase name="test_phase_one_submission_is_bound_only_on_explicit_first_review" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="216" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="11" time="8.668545"/>
<testcase name="test_notices_are_scoped_deduplicated_and_use_private_destination_email_rechecks_permission" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="237" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="10" time="7.126266"/>
<testcase name="test_publication_and_alternative_routes_remain_blocked_after_private_approval" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="263" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="12" time="10.876001"/>
<testcase name="test_rollback_preserves_draft_and_does_not_leave_tasks_notices_audit_or_send_mail" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="288" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="9" time="6.724795"/>
<testcase name="test_disabling_editorial_does_not_downgrade_a_phase_two_submission_to_phase_one" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="316" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="8" time="10.494746"/>
<testcase name="test_existing_coordinator_can_reassign_only_to_a_scoped_independent_validator" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="332" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="16" time="13.084118"/>
</testsuite>
</testsuite>
</testsuites>
@@ -0,0 +1,550 @@
import { Link } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { profileJson } from '@/lib/profile-json';
type Report = {
id: string;
version: number;
status: string;
reason: string;
resolution_reason: string | null;
source_record_version: number;
source_snapshot?: {
title?: string;
summary?: string;
description?: string;
} | null;
};
type Highlight = {
version: number;
status: string;
label: string;
note: string;
starts_at: string | null;
ends_at: string | null;
};
type Operation = {
id: number;
etag: string;
availability: {
state: string;
expires_at: string | null;
policy_pending: boolean;
};
editorial_state: string;
owner: boolean;
reviewer: boolean;
publisher: boolean;
reports: Report[];
highlight: Highlight | null;
history: { id: string; action: string; at: string; cycle: number }[];
};
const labels: Record<string, string> = {
available: 'Disponível',
withdrawn: 'Retirada',
expired: 'Expirada',
revalidation: 'Aguarda nova validação',
unavailable: 'Indisponível',
};
export default function OfferOperations({
id,
onChanged,
}: {
id: number;
onChanged: () => void;
}) {
const [value, setValue] = useState<Operation | null>(null);
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [reason, setReason] = useState('');
const [confirm, setConfirm] = useState(false);
const [plan, setPlan] = useState({
label: '',
starts_at: '',
ends_at: '',
note: '',
status: 'draft',
});
const base = `/minhas-oportunidades/ofertas/${id}`;
useEffect(() => {
let active = true;
profileJson(`${base}/operacoes`, {}, 'GET')
.then((result) => {
if (active) {
setValue(result);
if (result.highlight)
setPlan({
label: result.highlight.label,
starts_at: result.highlight.starts_at ?? '',
ends_at: result.highlight.ends_at ?? '',
note: result.highlight.note,
status: result.highlight.status,
});
}
})
.catch((error) => {
if (active) setMessage(error.message);
});
return () => {
active = false;
};
}, [base]);
async function command(path: string, data: object) {
if (busy) return;
setBusy(true);
setMessage('');
try {
const result = await profileJson(`${base}/${path}`, data);
setValue(result);
setConfirm(false);
onChanged();
setMessage(
'Operação guardada em privado. A publicação permanece bloqueada.',
);
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível concluir.',
);
} finally {
setBusy(false);
}
}
async function refresh(loadPlan = false) {
setBusy(true);
try {
const next: Operation = await profileJson(
`${base}/operacoes`,
{},
'GET',
);
setValue((previous) =>
loadPlan
? next
: { ...next, highlight: previous?.highlight ?? null },
);
if (loadPlan)
setPlan({
label: next.highlight?.label ?? '',
starts_at: next.highlight?.starts_at ?? '',
ends_at: next.highlight?.ends_at ?? '',
note: next.highlight?.note ?? '',
status: next.highlight?.status ?? 'draft',
});
setConfirm(false);
onChanged();
setMessage(
loadPlan
? 'Plano guardado carregado para conferência.'
: 'Disponibilidade e denúncias actualizadas. As observações e o plano local foram preservados.',
);
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível actualizar.',
);
} finally {
setBusy(false);
}
}
function owner(action: string) {
if (value)
void command('operacoes', {
action,
etag: value.etag,
confirm: true,
reason,
});
}
return (
<section className="space-y-4 rounded-lg border p-4">
<h2 className="font-semibold">Disponibilidade e acompanhamento</h2>
<button
type="button"
className="underline"
disabled={busy}
onClick={() => void refresh()}
>
Actualizar disponibilidade e denúncias
</button>
{value && (
<>
<p>{labels[value.availability.state] ?? 'Indisponível'}</p>
<p className="text-sm">
{value.availability.policy_pending
? 'Prazo de validade por definir.'
: value.availability.expires_at
? `Validade até ${new Date(value.availability.expires_at).toLocaleString('pt-MZ')}.`
: 'A contagem de validade inicia-se com a publicação da versão.'}{' '}
O NDA tem controlo independente.
</p>
{value.owner && (
<div className="space-y-3">
<label className="block">
Observação (opcional)
<textarea
className="block w-full rounded border p-2"
maxLength={1000}
value={reason}
onChange={(event) =>
setReason(event.target.value)
}
/>
</label>
<div className="flex flex-wrap gap-3">
<button
type="button"
className="rounded border p-2"
disabled={
busy ||
value.availability.state === 'withdrawn'
}
onClick={() => owner('withdraw')}
>
Retirar disponibilidade agora
</button>
{['withdrawn', 'expired'].includes(
value.availability.state,
) && (
<button
type="button"
className="rounded border p-2"
disabled={busy}
onClick={() =>
owner('request_reactivation')
}
>
Pedir nova validação para reactivar
</button>
)}
{value.availability.state ===
'revalidation' && (
<button
type="button"
className="rounded border p-2"
disabled={
busy ||
value.editorial_state !== 'approved'
}
onClick={() => owner('reactivate')}
>
Repor disponibilidade após validação
</button>
)}
</div>
</div>
)}
{value.reviewer && (
<div className="space-y-3">
<h3 className="font-semibold">
Denúncias no meu âmbito
</h3>
<label className="block">
Fundamento do tratamento
<textarea
className="block w-full rounded border p-2"
minLength={20}
maxLength={2000}
value={reason}
onChange={(event) =>
setReason(event.target.value)
}
/>
</label>
<label className="flex gap-2">
<input
type="checkbox"
checked={confirm}
onChange={(event) =>
setConfirm(event.target.checked)
}
/>
Confirmo a decisão para a versão consultada.
</label>
{value.reports.map((report) => (
<article
key={report.id}
className="space-y-2 rounded border p-3"
>
<p className="break-words whitespace-pre-line">
{report.reason}
</p>
{report.source_snapshot && (
<details>
<summary>
Conteúdo referido na denúncia
</summary>
<p className="font-semibold">
{report.source_snapshot.title}
</p>
<p className="break-words whitespace-pre-line">
{report.source_snapshot
.summary ??
report.source_snapshot
.description}
</p>
</details>
)}
<p className="text-sm">
Versão denunciada:{' '}
{report.source_record_version} ·{' '}
{
(
{
open: 'Por analisar',
in_review: 'Em análise',
resolved: 'Tratada',
} as Record<string, string>
)[report.status]
}
</p>
{report.resolution_reason && (
<p>{report.resolution_reason}</p>
)}
<div className="flex flex-wrap gap-3">
{(report.status === 'open'
? ['start_review']
: report.status === 'in_review'
? [
'resolve_no_withdrawal',
'resolve_withdrawal',
]
: []
).map((action) => (
<button
type="button"
className="underline"
key={action}
disabled={
busy ||
!confirm ||
reason.trim().length < 20
}
onClick={() =>
void command(
`denuncias/${report.id}`,
{
action,
etag: value.etag,
version:
report.version,
confirm,
reason,
},
)
}
>
{
(
{
start_review:
'Iniciar análise',
resolve_no_withdrawal:
'Concluir sem retirada',
resolve_withdrawal:
'Concluir e retirar disponibilidade',
} as Record<
string,
string
>
)[action]
}
</button>
))}
</div>
</article>
))}
</div>
)}
{value.publisher && (
<details className="space-y-3">
<summary>
Preparação administrativa de destaque
</summary>
<button
type="button"
className="underline"
disabled={busy}
onClick={() => void refresh(true)}
>
Carregar plano guardado (substitui os campos
locais)
</button>
<p>
Registo interno, sem cobrança, pagamento ou
promoção comercial activa.
</p>
<fieldset
disabled={busy}
className="grid gap-3 sm:grid-cols-2"
>
<label>
Designação
<input
className="block w-full rounded border p-2"
maxLength={150}
value={plan.label}
onChange={(event) =>
setPlan({
...plan,
label: event.target.value,
})
}
/>
</label>
<label>
Estado
<select
className="block w-full rounded border p-2"
value={plan.status}
onChange={(event) =>
setPlan({
...plan,
status: event.target.value,
})
}
>
<option value="draft">
Rascunho administrativo
</option>
<option value="archived">
Arquivado
</option>
</select>
</label>
<label>
Início previsto (UTC)
<input
className="block w-full rounded border p-2"
type="datetime-local"
value={plan.starts_at.replace(/Z$/, '')}
onChange={(event) =>
setPlan({
...plan,
starts_at: event.target.value
? `${event.target.value}Z`
: '',
})
}
/>
</label>
<label>
Fim previsto (UTC)
<input
className="block w-full rounded border p-2"
type="datetime-local"
value={plan.ends_at.replace(/Z$/, '')}
onChange={(event) =>
setPlan({
...plan,
ends_at: event.target.value
? `${event.target.value}Z`
: '',
})
}
/>
</label>
<label className="sm:col-span-2">
Fundamento
<textarea
className="block w-full rounded border p-2"
minLength={20}
maxLength={1000}
value={plan.note}
onChange={(event) =>
setPlan({
...plan,
note: event.target.value,
})
}
/>
</label>
<label>
<input
type="checkbox"
checked={confirm}
onChange={(event) =>
setConfirm(event.target.checked)
}
/>
Conferi a preparação administrativa.
</label>
<button
type="button"
className="rounded border p-2"
disabled={
!confirm ||
!plan.label ||
plan.note.trim().length < 20
}
onClick={() =>
void command(
'destaque-administrativo',
{
...plan,
starts_at:
plan.starts_at || null,
ends_at: plan.ends_at || null,
etag: value.etag,
version:
value.highlight?.version ??
0,
confirm,
},
)
}
>
Guardar preparação
</button>
</fieldset>
</details>
)}
<Link
className="inline-block underline"
href={`${base}/denunciar`}
>
Registar denúncia fundamentada
</Link>
<details>
<summary>Histórico de disponibilidade</summary>
<ul className="mt-2 space-y-2">
{value.history
.filter((event) =>
[
'offer_withdrawn',
'offer_reactivation_requested',
'offer_reactivated_privately',
'offer_expired',
].includes(event.action),
)
.map((event) => (
<li key={event.id}>
{
(
{
offer_withdrawn:
'Disponibilidade retirada',
offer_reactivation_requested:
'Nova validação pedida',
offer_reactivated_privately:
'Disponibilidade reposta',
offer_expired:
'Validade terminada',
} as Record<string, string>
)[event.action]
}{' '}
·{' '}
{new Date(event.at).toLocaleString(
'pt-MZ',
)}
</li>
))}
</ul>
</details>
</>
)}
<p role="status">{message}</p>
</section>
);
}
@@ -38,6 +38,12 @@ export default function PublicOfferCard({
Publicada em{' '}
{new Date(offer.published_at).toLocaleDateString('pt-MZ')}
</p>
{offer.expires_at && (
<p className="text-sm text-slate-600">
Validade até{' '}
{new Date(offer.expires_at).toLocaleString('pt-MZ')}
</p>
)}
</article>
);
}
+1
View File
@@ -11,6 +11,7 @@ export type PublicOffer = {
};
status: 'published';
published_at: string;
expires_at?: string | null;
revision: number;
url: string;
};
@@ -41,6 +41,7 @@ export default function EditorialWorkflow({
resourceAccess,
opportunitiesUrl,
offersUrl = null,
offerOperationsUrl = null,
opportunityPending,
clustersUrl,
portfolioUrl,
@@ -56,6 +57,7 @@ export default function EditorialWorkflow({
resourceAccess: boolean;
opportunitiesUrl: string | null;
offersUrl?: string | null;
offerOperationsUrl?: string | null;
opportunityPending: number;
clustersUrl?: string | null;
portfolioUrl?: string | null;
@@ -110,6 +112,7 @@ export default function EditorialWorkflow({
label: 'Ofertas — revisão privada',
href: offersUrl,
},
{ label: 'Ofertas — acompanhamento', href: offerOperationsUrl },
{
label: 'Biblioteca e editores',
href: resourceAccess ? '/management/portal-services' : null,
@@ -12,12 +12,17 @@ export default function OfferPreparations({
title: string | null;
reference: string;
state: string;
availability?: string;
}[];
prev_page_url: string | null;
next_page_url: string | null;
};
canCreate: boolean;
filters?: { state?: string };
filters?: {
state?: string;
operational?: boolean;
pending_reports?: boolean;
};
}) {
return (
<main className="mx-auto w-full max-w-5xl space-y-6 p-4 md:p-8">
@@ -25,7 +30,9 @@ export default function OfferPreparations({
<header className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">
As minhas ofertas
{filters.operational || filters.pending_reports
? 'Ofertas — acompanhamento'
: 'As minhas ofertas'}
</h1>
<p className="text-muted-foreground mt-2">
Prepare e guarde as suas ofertas num espaço privado.
@@ -62,6 +69,19 @@ export default function OfferPreparations({
</Link>
))}
</nav>
{filters.operational && (
<nav
className="flex flex-wrap gap-4"
aria-label="Acompanhamento de ofertas"
>
<Link href="/minhas-oportunidades/ofertas?operational=1">
Todas no meu âmbito
</Link>
<Link href="/minhas-oportunidades/ofertas?operational=1&pending_reports=1">
Denúncias pendentes
</Link>
</nav>
)}
{!records.data.length && (
<p>Ainda não existem ofertas neste âmbito.</p>
)}
@@ -77,6 +97,24 @@ export default function OfferPreparations({
<p className="text-muted-foreground mt-1 text-sm">
{record.reference} ·{' '}
{offerStates[record.state] ?? record.state}
{record.availability && (
<>
{' '}
·{' '}
{
(
{
available: 'Disponível',
withdrawn: 'Retirada',
expired: 'Expirada',
revalidation:
'Aguarda nova validação',
unavailable: 'Indisponível',
} as Record<string, string>
)[record.availability]
}
</>
)}
</p>
</li>
))}
+18
View File
@@ -15,6 +15,7 @@ import OfferParticularsFields, {
} from '@/components/offer-particulars';
import OfferPrivateResources from '@/components/offer-private-resources';
import OfferConfidentialAccess from '@/components/offer-confidential-access';
import OfferOperations from '@/components/offer-operations';
type Offer = OfferParticulars & {
price_mode?: string | null;
@@ -196,6 +197,23 @@ export default function OfferPreparation({
<div role="status" aria-live="polite">
{message}
</div>
{saved && (
<OfferOperations
id={saved.id}
onChanged={() => {
void profileJson(
`/minhas-oportunidades/ofertas/${saved.id}`,
{},
'GET',
)
.then((result) => {
setSaved(result.record);
setConfirmed(false);
})
.catch((error) => setMessage(error.message));
}}
/>
)}
{Object.keys(errors).length > 0 && (
<div
role="alert"
@@ -12,6 +12,12 @@ export default function OfferPublicDetail({ offer }: { offer: PublicOffer }) {
Voltar às ofertas
</Link>
<PublicOfferCard offer={offer} detail />
<Link
className="my-4 inline-block underline"
href={`/minhas-oportunidades/ofertas/${offer.id}/denunciar`}
>
Denunciar esta oferta
</Link>
{territoryId && (
<section className="mt-6">
<h2 className="mb-3 text-xl font-semibold">
+80
View File
@@ -0,0 +1,80 @@
import { Head } from '@inertiajs/react';
import { useState } from 'react';
import { profileJson } from '@/lib/profile-json';
import { createOperationKey } from '@/lib/operation-key';
export default function OfferReport({ offerId }: { offerId: number }) {
const [key] = useState(createOperationKey);
const [reason, setReason] = useState('');
const [confirm, setConfirm] = useState(false);
const [busy, setBusy] = useState(false);
const [sent, setSent] = useState(false);
const [message, setMessage] = useState('');
async function submit() {
setBusy(true);
try {
const result = await profileJson(
`/minhas-oportunidades/ofertas/${offerId}/denuncias`,
{ client_key: key, reason, confirm },
);
setSent(true);
setMessage(`Denúncia registada. Referência: ${result.id}.`);
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível registar a denúncia.',
);
} finally {
setBusy(false);
}
}
return (
<main className="mx-auto w-full max-w-2xl space-y-5 p-4">
<Head title="Denunciar oferta" />
<h1 className="text-2xl font-semibold">Denunciar oferta</h1>
<p>
Explique os factos que justificam a denúncia. A equipa
responsável irá analisá-la em privado.
</p>
<form
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
className="space-y-4"
>
<fieldset disabled={busy || sent} className="space-y-4">
<label className="block">
Fundamento
<textarea
className="mt-2 block w-full rounded border p-3"
required
minLength={20}
maxLength={2000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
<label className="flex gap-2">
<input
type="checkbox"
checked={confirm}
onChange={(event) =>
setConfirm(event.target.checked)
}
/>
Confirmo o envio destes factos para análise da equipa.
</label>
<button
type="submit"
className="rounded border px-4 py-2"
disabled={!confirm || reason.trim().length < 20}
>
Registar denúncia
</button>
</fieldset>
</form>
<p role="status">{message}</p>
</main>
);
}
+3
View File
@@ -10,3 +10,6 @@ Artisan::command('inspire', function () {
Schedule::command('investmoz:escalate-overdue-tasks')->everyFifteenMinutes()->withoutOverlapping();
Schedule::command('investmoz:dispatch-opportunity-alerts')->everyFiveMinutes()->withoutOverlapping();
if (config('private_offers.expiry_scheduler_enabled')) {
Schedule::command('investmoz:offers-expire --confirm')->everyMinute()->withoutOverlapping();
}
+7
View File
@@ -44,6 +44,7 @@ use App\Http\Controllers\PortalManagementController;
use App\Http\Controllers\PortalProfileController;
use App\Http\Controllers\PortalProfileInstallationController;
use App\Http\Controllers\PortalServicesController;
use App\Http\Controllers\PrivateOfferOperationsController;
use App\Http\Controllers\PrivateOfferPreparationController;
use App\Http\Controllers\ProactiveSupplyController;
use App\Http\Controllers\ProductExperimentController;
@@ -252,6 +253,12 @@ Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(func
Route::post('/{id}/confidencialidade', [$offers, 'confidentialityAction'])->whereNumber('id');
Route::get('/{id}/confidencial', [$offers, 'confidentialRead'])->whereNumber('id');
Route::get('/{id}/nda', [$offers, 'nda'])->whereNumber('id');
Route::get('/{id}/operacoes', [PrivateOfferOperationsController::class, 'show'])->whereNumber('id');
Route::post('/{id}/operacoes', [PrivateOfferOperationsController::class, 'owner'])->whereNumber('id');
Route::get('/{id}/denunciar', [PrivateOfferOperationsController::class, 'reportForm'])->whereNumber('id');
Route::post('/{id}/denuncias', [PrivateOfferOperationsController::class, 'report'])->whereNumber('id');
Route::post('/{id}/denuncias/{reportId}', [PrivateOfferOperationsController::class, 'handleReport'])->whereNumber('id')->whereUuid('reportId');
Route::post('/{id}/destaque-administrativo', [PrivateOfferOperationsController::class, 'highlight'])->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,351 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OfferAvailability;
use App\Services\OfferPublicCatalogue;
use App\Services\OpportunityManagement;
use App\Services\PortalProfileAdministration;
use App\Services\PrivateOfferConfidentiality;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Vite;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;
final class PrivateOfferOperationsTest extends TestCase
{
use RefreshDatabase;
private const BASE = '/minhas-oportunidades/ofertas';
private const REASON = 'Fundamento exclusivamente sintético para ensaio QA isolado.';
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Queue::fake();
Mail::fake();
Storage::fake('local');
config(['portal.profile_access_enabled' => true, 'editorial_workflow.enabled' => true, 'private_offers.validity_days' => null, 'private_offers.validity_policy_reference' => null]);
}
private function actor(?string $role = null, string $category = 'business_sale'): User
{
$actor = User::factory()->create(['two_factor_secret' => Crypt::encryptString('SYNTHETIC QA'), 'two_factor_confirmed_at' => now()]);
if ($role) {
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), ['user_id' => $actor->id,
'role' => $role, 'organization_id' => null, 'responsibilities' => [], 'opportunity_types' => [$category], 'source' => 'synthetic-test-fixture']);
}
return $actor;
}
private function login(User $actor): static
{
$actor = $actor->fresh();
return $this->actingAs($actor)->withSession(['login.id' => null, 'portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
}
private function draft(User $owner): Opportunity
{
$contact = (string) Str::uuid();
DB::table('contacts')->insert(['id' => $contact, 'full_name' => 'PRIVATE CONTACT CANARY', 'source_system' => 'portal-user-contact', 'external_id' => 'user:'.$owner->id]);
$id = $this->login($owner)->postJson(self::BASE.'/rascunhos', ['creation_key' => (string) Str::uuid(), 'opportunity_category' => 'business_sale', 'title' => 'PRIVATE TITLE CANARY',
'description' => 'PRIVATE DESCRIPTION CANARY', 'private_offer' => ['contact_id' => $contact, 'price_mode' => 'amount', 'amount' => '1200.01', 'currency' => 'MZN'], 'reason' => self::REASON])->assertCreated()->json('record.id');
return Opportunity::findOrFail($id);
}
private function etag(Opportunity $record): string
{
return app(OpportunityManagement::class)->etag($record->fresh());
}
private function approve(User $owner, Opportunity $record): void
{
$this->login($owner)->postJson(self::BASE.'/'.$record->id.'/submeter', ['etag' => $this->etag($record), 'confirm' => true])->assertOk();
$reviewer = $this->actor('validator');
foreach (['start_review', 'approve'] as $action) {
$this->login($reviewer)->postJson(self::BASE.'/'.$record->id.'/revisao', ['etag' => $this->etag($record), 'action' => $action, 'reason' => self::REASON])->assertOk();
}
}
private function publication(Opportunity $record, ?string $at = null): void
{
$record = $record->fresh();
$context = $record->workflow_context;
$payload = ['title' => 'Synthetic public summary', 'summary' => 'Safe synthetic summary.', 'category' => 'business_sale', 'province_id' => null, 'district_id' => null,
'price_mode' => 'amount', 'amount' => '1200.01', 'currency' => 'MZN'];
$hash = OfferPublicCatalogue::projectionHash($payload);
$context['offer_publication'] = ['contract' => OfferPublicCatalogue::CONTRACT, 'qa_fixture' => true, 'revision' => 1, 'payload' => $payload,
'approval' => ['state' => 'approved', 'hash' => $hash, 'actor_id' => 1, 'at' => now()->subYears(2)->toIso8601String()],
'publication' => ['state' => 'published', 'hash' => $hash, 'actor_id' => 2, 'at' => $at ?? now()->subHour()->toIso8601String()],
'offer_cycle' => $context['offer_operation']['cycle'] ?? 0, 'editorial_hash' => $context['approval']['hash'] ?? 'synthetic-legacy'];
$record->workflow_context = $context;
$record->save();
$this->app->instance(OfferPublicCatalogue::class, new OfferPublicCatalogue(true));
}
private function owner(User $actor, Opportunity $record, string $action, ?string $etag = null): TestResponse
{
return $this->login($actor)->postJson(self::BASE.'/'.$record->id.'/operacoes', ['action' => $action, 'etag' => $etag ?? $this->etag($record), 'confirm' => true]);
}
public function test_withdrawal_is_immediate_with_a_stale_form_and_excludes_every_public_consumer(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$this->publication($record);
$this->getJson('/api/v1/offers')->assertJsonPath('results.total', 1)->assertHeader('Cache-Control', 'no-store, private');
$this->owner($owner, $record, 'withdraw', str_repeat('0', 64))->assertOk()->assertJsonPath('availability.state', 'withdrawn');
$this->getJson('/api/v1/offers')->assertJsonPath('results.total', 0);
$this->getJson('/api/v1/offers/'.$record->id)->assertNotFound();
$this->get('/ofertas/'.$record->id)->assertNotFound();
$this->getJson('/api/v1/offers/map')->assertJsonPath('matched_count', 0)->assertJsonPath('features', []);
$this->assertStringNotContainsString('Synthetic public summary', $this->get('/api/v1/offers/export')->streamedContent());
$this->owner($owner, $record, 'withdraw')->assertOk();
$this->assertSame(1, DB::table('audit_events')->where('action', 'offer_withdrawn')->count());
$this->assertNotNull($record->fresh()->workflow_context['offer_publication']);
$this->assertNull($record->fresh()->published_at);
}
public function test_reactivation_requires_a_new_exact_validation_and_does_not_resurrect_old_publication(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$this->approve($owner, $record);
$this->publication($record);
$old = $record->fresh()->workflow_context['approval']['hash'];
$this->owner($owner, $record, 'withdraw')->assertOk();
$this->owner($owner, $record, 'reactivate')->assertConflict();
$this->owner($owner, $record, 'request_reactivation')->assertOk()->assertJsonPath('availability.state', 'revalidation');
$this->owner($owner, $record, 'reactivate')->assertConflict();
$this->approve($owner, $record);
$fresh = $record->fresh();
$this->assertNotSame($old, $fresh->workflow_context['approval']['hash']);
$this->assertSame(1, $fresh->workflow_context['submission']['payload']['operational_cycle']);
$this->owner($owner, $record, 'reactivate')->assertOk()->assertJsonPath('availability.state', 'available');
$this->getJson('/api/v1/offers/'.$record->id)->assertNotFound();
$this->publication($record);
$this->getJson('/api/v1/offers/'.$record->id)->assertOk()->assertJsonPath('data.title', 'Synthetic public summary');
$this->assertSame('draft', $record->fresh()->status);
$this->assertNull($record->fresh()->published_at);
}
public function test_withdrawal_during_review_can_be_reopened_by_the_owner_without_erasing_history(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$this->login($owner)->postJson(self::BASE.'/'.$record->id.'/submeter', ['etag' => $this->etag($record), 'confirm' => true])->assertOk();
$submission = $record->fresh()->workflow_context['submission'];
$this->owner($owner, $record, 'withdraw')->assertOk();
$this->owner($owner, $record, 'request_reactivation')->assertOk();
$history = $record->fresh()->workflow_context['decision_history'];
$this->assertSame($submission['hash'], end($history)['submission']['hash']);
$this->login($owner)->getJson(self::BASE.'/'.$record->id)->assertOk()->assertJsonPath('record.canEdit', true);
}
public function test_expiry_is_immediate_at_the_boundary_frozen_and_independent_of_nda_and_scheduler(): void
{
config(['private_offers.validity_days' => 7, 'private_offers.validity_policy_reference' => 'SYNTHETIC QA POLICY NOT A FINAL DEADLINE']);
$this->travelTo(now()->startOfSecond());
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$this->approve($owner, $record);
$start = now()->toIso8601String();
$this->publication($record, $start);
$this->getJson('/api/v1/offers/'.$record->id)->assertOk()->assertJsonPath('data.expires_at', app(OfferAvailability::class)->record($record->fresh())['expires_at']);
config(['private_offers.validity_days' => 365, 'private_offers.nda' => null]);
$this->travel(7)->days();
$view = app(OfferAvailability::class)->record($record->fresh());
$this->assertSame('expired', $view['state']);
$this->getJson('/api/v1/offers?currency=MZN&min_amount=1')->assertOk()->assertJsonPath('results.total', 0);
$this->getJson('/api/v1/offers/'.$record->id)->assertNotFound();
$before = $record->fresh()->management_version;
$this->artisan('investmoz:offers-expire')->assertSuccessful();
$this->assertSame($before, $record->fresh()->management_version);
$this->artisan('investmoz:offers-expire', ['--confirm' => true])->assertSuccessful();
$this->artisan('investmoz:offers-expire', ['--confirm' => true])->assertSuccessful();
$this->assertSame(1, DB::table('audit_events')->where('action', 'offer_expired')->count());
$this->owner($owner, $record, 'reactivate')->assertConflict();
$this->travelBack();
}
public function test_no_deadline_is_invented_when_the_policy_is_pending(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$this->approve($owner, $record);
$this->publication($record, now()->subYear()->toIso8601String());
$this->assertSame(['state' => 'available', 'expires_at' => null, 'policy_pending' => true], app(OfferAvailability::class)->record($record->fresh()));
$this->assertFalse(config('private_offers.expiry_scheduler_enabled'));
$this->assertNull(config('private_offers.validity_days'));
}
public function test_strangers_reviewers_wrong_scopes_and_auditors_cannot_withdraw_or_reactivate(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
foreach ([$this->actor(), $this->actor('promoter'), $this->actor('validator'), $this->actor('auditor')] as $other) {
$this->owner($other, $record, 'withdraw')->assertForbidden();
}
$this->owner($owner, $record, 'withdraw')->assertOk();
$this->owner($owner, $record, 'request_reactivation', str_repeat('0', 64))->assertConflict();
$this->assertSame('withdrawn', app(OfferAvailability::class)->record($record->fresh())['state']);
}
public function test_reports_are_private_idempotent_and_treated_in_the_existing_dashboard_and_tasks(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$this->publication($record);
$reporter = $this->actor();
$key = (string) Str::uuid();
$input = ['client_key' => $key, 'reason' => 'PRIVATE REPORT CANARY with concrete synthetic facts.', 'confirm' => true];
$url = self::BASE.'/'.$record->id;
$this->login($reporter)->postJson($url.'/denuncias', $input)->assertCreated();
$this->postJson($url.'/denuncias', $input)->assertCreated()->assertJsonPath('replayed', true);
$this->postJson($url.'/denuncias', [...$input, 'reason' => self::REASON])->assertConflict();
$this->assertSame(1, DB::table('tasks')->where('task_type', 'offer_report_'.$key)->count());
$this->assertStringNotContainsString('PRIVATE REPORT CANARY', json_encode($record->fresh()->workflow_context));
$reviewer = $this->actor('validator');
$this->login($reviewer)->getJson('/management/editorial')->assertOk()->assertJsonPath('offerOperationsUrl', '/minhas-oportunidades/ofertas?operational=1');
$this->get(self::BASE.'?operational=1&pending_reports=1')->assertOk();
$this->getJson($url.'/operacoes')->assertOk()->assertJsonPath('reports.0.reason', $input['reason']);
$this->getJson($url.'/operacoes')->assertJsonPath('reports.0.source_snapshot.title', 'Synthetic public summary');
$handle = ['etag' => $this->etag($record), 'version' => 1, 'action' => 'start_review', 'reason' => self::REASON, 'confirm' => true];
$this->login($owner)->postJson($url.'/denuncias/'.$key, $handle)->assertForbidden();
$this->login($reviewer)->postJson($url.'/denuncias/'.$key, $handle)->assertOk();
$this->postJson($url.'/denuncias/'.$key, [...$handle, 'action' => 'resolve_withdrawal'])->assertConflict();
$this->postJson($url.'/denuncias/'.$key, [...$handle, 'etag' => $this->etag($record), 'version' => 2, 'action' => 'resolve_withdrawal'])->assertOk()->assertJsonPath('availability.state', 'withdrawn');
$this->assertSame('done', DB::table('tasks')->where('task_type', 'offer_report_'.$key)->value('status'));
$this->login($owner)->getJson($url.'/operacoes')->assertOk()->assertJsonPath('reports', []);
$this->getJson('/api/v1/offers/'.$record->id)->assertNotFound();
$this->login($reporter)->postJson($url.'/denuncias', $input)->assertCreated()->assertJsonPath('replayed', true);
}
public function test_drafts_cannot_be_reported_by_unrelated_users_and_report_read_requires_current_scope_and_mfa(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$other = $this->actor();
$this->login($other)->postJson(self::BASE.'/'.$record->id.'/denuncias', ['client_key' => (string) Str::uuid(), 'reason' => self::REASON, 'confirm' => true])->assertNotFound();
$this->getJson(self::BASE.'/'.$record->id.'/operacoes')->assertForbidden();
$this->login($this->actor('validator', 'real_estate'))->get(self::BASE.'?operational=1')->assertOk();
$this->getJson(self::BASE.'/'.$record->id.'/operacoes')->assertForbidden();
$this->login($this->actor('validator'))->withSession(['portal.mfa_at' => time() - 1000])->get(self::BASE.'?operational=1')->assertForbidden();
$this->login($this->actor('auditor'))->get(self::BASE.'?operational=1')->assertForbidden();
}
public function test_highlights_are_versioned_administrative_only_and_cannot_set_commercial_flags(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$publisher = $this->actor('publisher');
$url = self::BASE.'/'.$record->id;
$plan = ['etag' => $this->etag($record), 'version' => 0, 'label' => 'Synthetic plan', 'status' => 'draft', 'note' => self::REASON, 'confirm' => true, 'is_featured' => true, 'payment_enabled' => true];
$this->login($owner)->postJson($url.'/destaque-administrativo', $plan)->assertForbidden();
$this->login($this->actor('validator'))->postJson($url.'/destaque-administrativo', $plan)->assertForbidden();
$this->login($publisher)->postJson($url.'/destaque-administrativo', $plan)->assertOk()->assertJsonPath('commercial_activation', false);
$this->postJson($url.'/destaque-administrativo', $plan)->assertConflict();
$this->postJson($url.'/destaque-administrativo', [...$plan, 'etag' => $this->etag($record), 'version' => 1, 'status' => 'active'])->assertUnprocessable();
$this->postJson($url.'/destaque-administrativo', [...$plan, 'etag' => $this->etag($record), 'version' => 1, 'status' => 'archived'])->assertOk()->assertJsonPath('highlight.version', 2);
$this->assertFalse($record->fresh()->is_featured);
$this->assertNull($record->fresh()->published_at);
$this->login($owner)->getJson($url.'/operacoes')->assertJsonPath('highlight', null);
}
public function test_a_withdrawn_first_draft_still_requires_its_first_editorial_validation(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$this->owner($owner, $record, 'withdraw')->assertOk();
$this->owner($owner, $record, 'request_reactivation')->assertOk()->assertJsonPath('availability.state', 'revalidation');
$this->owner($owner, $record, 'reactivate')->assertConflict();
$this->approve($owner, $record);
$this->owner($owner, $record, 'reactivate')->assertOk();
$this->assertSame('draft', $record->fresh()->status);
}
public function test_withdrawal_and_revalidation_do_not_restore_previous_nda_grants(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$this->approve($owner, $record);
$bytes = "%PDF-1.7\nSYNTHETIC QA NOT A LEGAL NDA\n%%EOF";
$docId = (string) Str::uuid();
$sha = hash('sha256', $bytes);
Storage::disk('local')->put('qa-nda/'.$docId, Crypt::encryptString($bytes));
DB::table('documents')->insert(['id' => $docId, 'file_name' => 'qa-only.pdf', 'media_type' => 'application/pdf', 'storage_key' => 'qa-nda/'.$docId, 'storage_encryption' => 'laravel',
'sha256' => $sha, 'status' => 'active', 'malware_scan_status' => 'clean', 'malware_scanned_sha256' => $sha, 'malware_engine' => 'QA-Test-Double', 'version_no' => 1, 'data_classification' => 'restricted']);
config(['private_offers.nda' => ['document_id' => $docId, 'version' => 1, 'sha256' => $sha, 'approval_reference' => 'SYNTHETIC QA NOT LEGAL APPROVAL']]);
$nda = app(PrivateOfferConfidentiality::class)->nda();
$interested = $this->actor();
$url = self::BASE.'/'.$record->id;
$input = ['action' => 'accept', 'confirm' => true, 'version_hash' => $record->fresh()->workflow_context['approval']['hash'], 'nda_sha256' => $sha, 'nda_fingerprint' => $nda['fingerprint']];
$this->login($interested)->postJson($url.'/confidencialidade', $input)->assertOk();
$acceptance = $this->getJson($url.'/confidencialidade')->json('acceptance');
$this->login($owner)->postJson($url.'/confidencialidade', [...$input, 'action' => 'authorize', 'interested_user_id' => $interested->id, 'acceptance_id' => $acceptance['id'], 'reason' => self::REASON])->assertOk();
$this->login($interested)->getJson($url.'/confidencial')->assertOk();
$this->owner($owner, $record, 'withdraw')->assertOk();
$this->login($interested)->getJson($url.'/confidencial')->assertConflict();
$this->owner($owner, $record, 'request_reactivation')->assertOk();
$this->approve($owner, $record);
$this->owner($owner, $record, 'reactivate')->assertOk();
$this->login($interested)->getJson($url.'/confidencial')->assertForbidden();
$this->assertSame($acceptance['id'], $record->fresh()->workflow_context['offer_confidentiality']['acceptances'][(string) $interested->id]['id']);
}
public function test_operational_and_report_screens_use_real_compiled_entries_and_private_cache_headers(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$directory = getenv('EDITORIAL_QA_BUILD_DIRECTORY') ?: 'build';
$manifest = json_decode(file_get_contents(public_path($directory.'/manifest.json')), true, 512, JSON_THROW_ON_ERROR);
$this->app->instance(Vite::class, (new Vite)->useBuildDirectory($directory)->useHotFile(storage_path('phpunit-offers-no-hot')));
foreach (['offers/preparation' => self::BASE.'/'.$record->id, 'offers/report' => self::BASE.'/'.$record->id.'/denunciar'] as $page => $url) {
$key = 'resources/js/pages/'.$page.'.tsx';
$this->assertArrayHasKey($key, $manifest);
$response = $this->login($owner)->get($url, ['Accept' => 'text/html'])->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertSee($manifest[$key]['file'], false);
if ($page === 'offers/report') {
$response->assertDontSee('PRIVATE CONTACT CANARY')->assertDontSee('PRIVATE DESCRIPTION CANARY');
}
}
$reviewer = $this->actor('validator');
foreach (['offers/preparation-index' => self::BASE.'?operational=1', 'management/editorial-workflow' => '/management/editorial'] as $page => $url) {
$key = 'resources/js/pages/'.$page.'.tsx';
$this->assertArrayHasKey($key, $manifest);
$this->login($reviewer)->get($url, ['Accept' => 'text/html'])->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertSee($manifest[$key]['file'], false);
}
}
public function test_an_edited_private_approval_does_not_extend_the_existing_public_deadline(): void
{
config(['private_offers.validity_days' => 7, 'private_offers.validity_policy_reference' => 'SYNTHETIC QA FIRST']);
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$this->approve($owner, $record);
$this->publication($record);
$deadline = app(OfferAvailability::class)->record($record->fresh())['expires_at'];
$this->login($owner)->postJson(self::BASE.'/'.$record->id.'/revisao', ['action' => 'request_revision', 'etag' => $this->etag($record), 'reason' => self::REASON])->assertOk();
$this->postJson(self::BASE.'/'.$record->id.'/guardar', ['title' => 'Changed private title', 'etag' => $this->etag($record), 'reason' => self::REASON])->assertOk();
config(['private_offers.validity_days' => 30, 'private_offers.validity_policy_reference' => 'SYNTHETIC QA SECOND']);
$this->approve($owner, $record);
$this->assertSame($deadline, app(OfferAvailability::class)->record($record->fresh())['expires_at']);
$context = $record->fresh()->workflow_context;
unset($context['offer_publication']['editorial_hash']);
$record->forceFill(['workflow_context' => $context])->save();
$this->assertSame($deadline, app(OfferAvailability::class)->record($record->fresh())['expires_at']);
$this->getJson('/api/v1/offers/'.$record->id)->assertOk()->assertJsonPath('data.title', 'Synthetic public summary');
}
}