feat(offers): complete phase five operational controls in QA
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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.
|
||||
[33m[PLUGIN_TIMINGS] [0mYour 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 : [1m[31merror:[39m[0m Formatting issues found
|
||||
At line:2 char:1
|
||||
+ npm.cmd run check *> outputs/offers-private-phase5-20261114/global-fo ...
|
||||
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
+ CategoryInfo : NotSpecified: ([1m[31merror:...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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user