Complete APIEX documentary preparation and private contextual SWOT in existing dossiers

This commit is contained in:
2026-11-10 18:00:06 +02:00
parent 7476dc3430
commit 37854d0231
17 changed files with 2982 additions and 24 deletions
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentPreparationWorkspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class InvestmentPreparationController extends Controller
{
public function show(Request $request, string $id, InvestmentPreparationWorkspace $workspace): JsonResponse|Response
{
$request->validate(['opportunity' => 'nullable|string|max:150', 'history_scope' => 'nullable|in:dossier', 'history_before' => 'nullable|integer|min:1']);
$data = $workspace->view($request->user(), $id, $request->query('opportunity'), $request->filled('history_before') ? $request->integer('history_before') : null, $request->query('history_scope') === 'dossier');
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/preparation', ['workspace' => $data]);
}
public function change(Request $request, string $id, InvestmentPreparationWorkspace $workspace): JsonResponse
{
return response()->json($workspace->change($request->user(), $id, $request->all()));
}
public function preview(Request $request, string $id, InvestmentPreparationWorkspace $workspace): \Illuminate\Http\Response
{
$request->validate(['opportunity' => 'nullable|string|max:150', 'history_scope' => 'nullable|in:dossier']);
$data = $workspace->view($request->user(), $id, $request->query('opportunity'), null, $request->query('history_scope') === 'dossier');
return response()->view('investments.preparation-index', ['workspace' => $data, 'manifest' => null])->header('Content-Security-Policy', "sandbox; default-src 'none'; style-src 'unsafe-inline'")->header('X-Content-Type-Options', 'nosniff');
}
public function download(Request $request, string $id, string $exportId, InvestmentPreparationWorkspace $workspace): \Illuminate\Http\Response
{
$request->validate(['opportunity' => 'nullable|string|max:150', 'history_scope' => 'nullable|in:dossier']);
$bundle = $workspace->bundle($request->user(), $id, $request->query('opportunity'), $exportId, $request->query('history_scope') === 'dossier');
return response($bundle['bytes'], 200, ['Content-Type' => 'application/zip', 'Content-Disposition' => 'attachment; filename="preparacao-apiex-'.$id.'.zip"', 'X-Export-Id' => $exportId, 'X-Content-Type-Options' => 'nosniff']);
}
public function export(Request $request, string $id, InvestmentPreparationWorkspace $workspace): \Illuminate\Http\Response
{
$body = [...$request->all(), 'action' => 'apiex_export'];
$state = $workspace->change($request->user(), $id, $body);
$bundle = $workspace->bundle($request->user(), $id, $state['opportunity'], $body['operation_key']);
return response($bundle['bytes'], 200, ['Content-Type' => 'application/zip', 'Content-Disposition' => 'attachment; filename="preparacao-apiex-'.$id.'.zip"', 'X-Export-Id' => $bundle['export_id'], 'X-Content-Type-Options' => 'nosniff']);
}
}
@@ -0,0 +1,489 @@
<?php
namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\EconomicCluster;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Continuation of the existing private dossier, its documentary assessment and proactive evidence. */
final class InvestmentPreparationWorkspace
{
private const SOURCE = 'investor-preparation';
public function __construct(private readonly VirtualDesk $desk) {}
/** @return list<array<array-key,mixed>> */
private function rows(mixed $value): array
{
if (! is_array($value)) {
throw new \LogicException('Dossier list is not recoverable; original retained.');
}
$rows = [];
foreach ($value as $row) {
if (! is_array($row)) {
throw new \LogicException('Dossier row is not recoverable; original retained.');
} $rows[] = $row;
}
return $rows;
}
/** @param array<array-key,mixed> $data */
private function hash(array $data): string
{
return hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR));
}
private function events(string $id, ?string $opportunity): Builder
{
return DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'desk_cases')->where('entity_id', $id)
->where('after_data->scope', $opportunity ?? 'dossier');
}
/** @return array<array-key,mixed> */
private function decode(?\stdClass $event): array
{
return $event ? json_decode(Crypt::decryptString(json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['snapshot']), true, 512, JSON_THROW_ON_ERROR)
: ['version' => 0, 'apiex' => ['additional_documents' => [], 'note' => '', 'review' => null, 'exports' => [], 'external' => []],
'swot' => ['object_key' => '', 'scope' => '', 'rows' => [], 'source_fingerprint' => '', 'review' => null, 'actions' => []]];
}
/** @return array<array-key,mixed> */
private function current(string $id, ?string $opportunity): array
{
return $this->decode($this->events($id, $opportunity)->orderByRaw("(after_data->>'version')::integer DESC")->first());
}
/** @return array{0: \stdClass, 1: array<array-key,mixed>, 2: ?string, 3: array<array-key,mixed>, 4: bool} */
private function context(User $actor, string $id, ?string $opportunity, bool $initialScope = false): array
{
abort_unless(app(InvestorPath::class)->available() && app(PrivateInvestmentPreparation::class)->available(), 404);
$case = $this->desk->case($actor, $id);
abort_unless($this->desk->owns($actor, $case, false) || $actor->can('manage-portal'), 403);
$context = app(InvestorContext::class)->state($case);
$scope = app(InvestmentConsumerScope::class);
$historical = false;
abort_if($initialScope && ! $this->events($id, null)->exists(), 404);
$choices = array_column(array_column($context['opportunity_choices'] ?? [], 'opportunity'), 'key');
if ((($opportunity && ! in_array($opportunity, $choices, true)) || ($initialScope && $choices)) && $this->events($id, $opportunity)->exists()) {
$saved = $this->current($id, $opportunity);
$last = $saved['apiex']['exports'] ? $saved['apiex']['exports'][array_key_last($saved['apiex']['exports'])] : null;
$oldContext = $saved['context_snapshot'] ?? $last['prepared']['context'] ?? $saved['swot']['sources_snapshot']['context'] ?? null;
abort_unless(is_array($oldContext), 409, 'Contexto histórico conservado; snapshot de consulta indisponível.');
$context = $oldContext;
$historical = true;
} else {
$opportunity = $scope->key($context, $opportunity, true);
$context = $scope->context($context, $opportunity);
}
$documents = app(InvestorContext::class)->preparationDocuments($id);
// Reuse request-level document authorization, including for a dossier team member.
$documents = array_values(array_filter($documents, function ($document) use ($actor, $id) {
$link = DB::table('desk_documents')->where('id', $document['id'])->where('case_id', $id)->first();
if ($link?->request_id) {
try {
$this->desk->request($actor, $link->request_id);
} catch (HttpException $e) {
if (in_array($e->getStatusCode(), [403, 404], true)) {
return false;
} throw $e;
}
}
return true;
}));
foreach ($documents as &$document) {
$record = DB::table('documents')->where('id', $document['document_id'])->first();
$document['available'] = $record && $record->status === 'active' && $record->malware_scan_status === 'clean'
&& hash_equals(trim($record->sha256), trim((string) $record->malware_scanned_sha256));
}
unset($document);
$unique = [];
foreach ($documents as $document) {
$unique[$document['document_id']] = $document;
}
return [$case, $context, $opportunity, array_values($unique), $historical];
}
/** @param array<array-key,mixed> $context
* @return array<array-key,mixed> */
private function sources(User $actor, \stdClass $case, array $context, string $object): array
{
$objects = [['key' => 'investment:'.$case->id, 'label' => $case->title, 'kind' => 'investment']];
$evidence = [];
$refs = array_values($context['selection'] ?? []);
foreach ($context['opportunity_choices'] ?? [] as $choice) {
$refs[] = $choice['opportunity'];
}
foreach ($refs as $ref) {
if (! is_array($ref) || empty($ref['key'])) {
continue;
}
try {
$live = app(InvestorContext::class)->reference($ref['key']);
} catch (ModelNotFoundException|HttpException $e) {
$live = ['key' => $ref['key'], 'unavailable' => true];
}
$evidence[$ref['key']] = ['key' => $ref['key'], 'label' => $ref['title'] ?? $ref['key'], 'consulted' => $ref, 'current' => $live];
if (in_array($ref['kind'] ?? '', ['territory', 'sector'], true)) {
$objects[] = ['key' => $ref['key'], 'label' => $ref['title'], 'kind' => $ref['kind']];
}
if (($ref['kind'] ?? '') === 'portfolio') {
$portfolio = InvestmentPortfolioController::visible()->whereKey($ref['id'])->first();
if ($portfolio?->cluster_id) {
$cluster = EconomicCluster::find($portfolio->cluster_id);
if ($cluster) {
$objects[] = ['key' => 'cluster:'.$cluster->id, 'label' => $cluster->getAttribute('name'), 'kind' => 'cluster'];
}
}
}
}
// Sector/territory may have been deliberately entered rather than selected as a source.
foreach ([['sector', 'sector_id'], ['territory', 'province_id'], ['territory', 'district_id'], ['territory', 'admin_post_id'], ['territory', 'locality_id']] as [$kind, $field]) {
if (! empty($context['fields'][$field])) {
$key = $kind.':'.$context['fields'][$field];
try {
$ref = app(InvestorContext::class)->reference($key);
$objects[] = ['key' => $key, 'label' => $ref['title'], 'kind' => $kind];
$evidence[$key] = ['key' => $key, 'label' => $ref['title'], 'consulted' => $ref, 'current' => $ref];
} catch (ModelNotFoundException|HttpException $e) { /* Keep the declared context; do not invent evidence. */
}
}
}
// A manually chosen cluster must already be a public, visible portfolio association.
if (str_starts_with($object, 'cluster:')) {
$clusterId = substr($object, 8);
$visible = InvestmentPortfolioController::visible()->where('cluster_id', $clusterId)->exists();
$cluster = $visible ? EconomicCluster::find($clusterId) : null;
$objects[] = ['key' => $object, 'label' => $cluster ? $cluster->getAttribute('name') : 'Cluster da análise guardada — fonte actual indisponível', 'kind' => 'cluster'];
}
foreach ($objects as $item) {
if ($item['kind'] === 'cluster') {
$evidence[$item['key']] = ['key' => $item['key'], 'label' => 'Competitividade — '.$item['label'],
'current' => InvestmentPortfolioController::visible()->where('cluster_id', substr($item['key'], 8))->exists() ? app(ClusterCompetitiveness::class)->publicData(substr($item['key'], 8)) : null, 'notice' => 'Versão pública aprovada; ausência de análise não prova ausência de oferta/concorrência.'];
}
}
$unique = [];
foreach ($objects as $item) {
$unique[$item['key']] = $item;
}
return [array_values($unique), array_values($evidence)];
}
/** @return array<array-key,mixed> */
public function view(User $actor, string $id, ?string $opportunity = null, ?int $historyBefore = null, bool $initialScope = false): array
{
[$case, $context, $opportunity, $documents, $historical] = $this->context($actor, $id, $opportunity, $initialScope);
$state = $this->current($id, $opportunity);
[$objects, $sources] = $this->sources($actor, $case, $context, $state['swot']['object_key']);
$payload = $this->desk->decode($case->payload);
$reused = ['investor' => Arr::only($payload, ['name', 'email']), 'profile' => Arr::only($payload['profile'] ?? [], ['phone', 'nuit', 'address', 'country_code', 'nationality', 'residence_country_code']), 'company' => Arr::only($payload['company'] ?? [], ['id', 'name', 'nuit', 'registration', 'address', 'type']), 'company_version' => $payload['company_version'] ?? null, 'notice' => 'Valores autorizados conservados no dossier; não foram substituídos por dados recentes de outra origem.'];
if ($historical && isset($state['reused_snapshot'])) {
$reused = $state['reused_snapshot'];
}
foreach ($documents as $document) {
$sources[] = ['key' => 'document:'.$document['document_id'], 'label' => $document['file_name'], 'current' => $document, 'notice' => 'Documento privado do dossier; disponibilidade não certifica o conteúdo.'];
}
$assessment = app(PrivateInvestmentPreparation::class)->assess($context, $documents);
$issues = $assessment['missing_labels'];
if (! $assessment['enabled'] || ! $assessment['period_covered'] || ! $assessment['scope_covered'] || $assessment['mode'] === 'indeterminate') {
$issues[] = 'Completar o enquadramento da preparação existente.';
}
foreach ($assessment['requirements'] as &$requirement) {
$document = collect($this->rows($documents))->firstWhere('document_id', $requirement['document_id']);
$requirement['available'] = $document['available'] ?? false;
if ($requirement['state'] === 'indeterminate' || ($requirement['required'] && ! $requirement['available'])) {
$issues[] = $requirement['label'];
}
}
unset($requirement);
$selected = array_values(array_unique([...collect($this->rows($assessment['requirements']))->where('required', true)->pluck('document_id')->filter()->all(), ...$state['apiex']['additional_documents']]));
foreach ($selected as $doc) {
if (! (collect($this->rows($documents))->firstWhere('document_id', $doc)['available'] ?? false)) {
$issues[] = 'Anexo indisponível: '.$doc;
}
}
$documentary = array_values(array_filter($documents, fn ($d) => in_array($d['document_id'], $selected, true)));
$fingerprint = $this->hash(['context' => $context, 'reused' => $reused, 'assessment' => $assessment, 'documents' => $documentary, 'note' => $state['apiex']['note']]);
$swotFingerprint = $this->hash($this->sourceSnapshot($context, $reused, $sources, $state['swot']['object_key'], $state['swot']['rows']));
$external = collect($this->rows($state['apiex']['external']));
$apiState = $external->contains('kind', 'decision') ? 'decision_received' : ($external->contains('kind', 'delivery') ? 'delivery_recorded' : (! $issues && ($state['apiex']['review']['fingerprint'] ?? null) === $fingerprint ? 'ready' : 'preparing'));
$query = $opportunity ? '?opportunity='.urlencode($opportunity) : '';
$editQuery = $historical ? '' : $query;
$savedScopes = DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'desk_cases')->where('entity_id', $id)->selectRaw("DISTINCT after_data->>'scope' as scope")->get()->map(fn ($row) => ['key' => $row->scope, 'label' => $row->scope === 'dossier' ? 'Contexto inicial do dossier' : $row->scope, 'href' => '/investimentos/'.$id.'/preparacao?'.($row->scope === 'dossier' ? 'history_scope=dossier' : 'opportunity='.urlencode($row->scope))])->all();
$historyRows = $this->events($id, $opportunity)->when($historyBefore !== null, fn ($q) => $q->whereRaw("(after_data->>'version')::integer < ?", [$historyBefore]))->orderByRaw("(after_data->>'version')::integer DESC")->limit(26)->get();
$history = $historyRows->take(25)->map(fn ($e) => ['version' => json_decode($e->after_data, true)['version'], 'action' => $e->action, 'at' => $e->occurred_at, 'actor' => $e->actor_user_id, 'operation_key' => $e->correlation_id])->values()->all();
return ['case' => ['id' => $id, 'title' => $case->title, 'closed' => $case->closed_at !== null], 'opportunity' => $opportunity, 'opportunity_label' => $context['selection']['opportunity']['title'] ?? null,
'historical' => $historical, 'initial_scope' => $initialScope, 'saved_scopes' => $savedScopes, 'version' => $state['version'], 'apiex' => $state['apiex'], 'swot' => $state['swot'], 'assessment' => $assessment, 'context' => $context, 'reused' => $reused,
'documents' => $documents, 'selected_documents' => $documentary, 'api_issues' => array_values(array_unique($issues)), 'api_state' => $apiState,
'api_fingerprint' => $fingerprint, 'swot_fingerprint' => $swotFingerprint,
'swot_review_required' => $state['swot']['source_fingerprint'] !== $swotFingerprint || ($state['swot']['review']['fingerprint'] ?? null) !== $swotFingerprint,
'objects' => $objects, 'sources' => $sources, 'can_write' => ! $historical && ! $case->closed_at && $this->desk->owns($actor, $case),
'can_review' => ! $historical && ! $case->closed_at && ($this->desk->owns($actor, $case) || $actor->can('manage-portal')),
'links' => ['dossier' => '/investimentos/'.$id.$editQuery, 'context' => '/investimentos/'.$id.'/contexto/editar'.$editQuery,
'documents' => '/balcao-virtual/dossiers/'.$id.$query, 'partners' => '/parcerias/painel?context='.$id.($opportunity ? '&opportunity='.urlencode($opportunity) : ''),
'library' => '/documentos?context='.$id.($opportunity ? '&opportunity='.urlencode($opportunity) : ''),
'editorial' => '/acesso-gestao', 'idea' => '/investimentos/'.$id.'/contexto/editar'.$query.'#project-summary', 'opportunity' => '/investimentos/'.$id.'/contexto/editar'.$query.'#project-summary', 'risk' => '/investimentos/'.$id.$query.'#tarefas'],
'history' => $history, 'history_next' => $historyRows->count() > 25 ? ($history[count($history) - 1]['version'] ?? null) : null];
}
/** @param array<array-key,mixed> $context
* @param array<array-key,mixed> $reused
* @param array<array-key,mixed> $sources
* @param array<array-key,mixed> $rows
* @return array<array-key,mixed> */
private function sourceSnapshot(array $context, array $reused, array $sources, string $object, array $rows): array
{
$used = array_column($rows, 'source_key');
$tracked = array_values(array_filter($sources, fn ($source) => ! str_starts_with($source['key'], 'document:') || in_array($source['key'], $used, true)));
return ['context' => $context, 'reused' => $reused, 'sources' => $tracked, 'object' => $object];
}
/** @return array<array-key,mixed>|null */
public function continuation(User $actor, string $id, ?string $opportunity, mixed $actionId): ?array
{
if (! is_string($actionId) || ! Str::isUuid($actionId)) {
return null;
}
[, , $scope] = $this->context($actor, $id, $opportunity);
return collect($this->rows($this->current($id, $scope)['swot']['actions']))->firstWhere('id', $actionId);
}
/** @return list<string> */
public function taskKeys(User $actor, string $id, string $opportunity): array
{
[, , $scope] = $this->context($actor, $id, $opportunity);
return array_values(array_map(fn ($action) => 'swot:'.$action['id'], $this->current($id, $scope)['swot']['actions']));
}
/** @param array<array-key,mixed> $input
* @param array<array-key,mixed> $view
* @return array<array-key,mixed> */
private function draft(array $input, array $view): array
{
$data = Validator::make($input, ['object_key' => ['required', Rule::in(array_column($view['objects'], 'key'))], 'scope' => 'present|nullable|string|max:4000',
'source_fingerprint' => 'present|string|size:64', 'rows' => 'present|array|list|max:80', 'rows.*' => 'array:id,dimension,text,reference,source_key,date,classification',
'rows.*.id' => 'required|uuid|distinct', 'rows.*.dimension' => 'required|in:strength,weakness,opportunity,threat',
'rows.*.classification' => 'required|in:documented_fact,hypothesis,unconfirmed', 'rows.*.text' => 'present|nullable|string|max:4000',
'rows.*.reference' => 'present|nullable|string|max:4000', 'rows.*.source_key' => ['nullable', Rule::in(array_column($view['sources'], 'key'))], 'rows.*.date' => 'nullable|date_format:Y-m-d'])->validate();
$snapshot = $this->sourceSnapshot($view['context'], $view['reused'], $view['sources'], $data['object_key'], $data['rows']);
return [...$data, 'sources_snapshot' => $snapshot, 'snapshot_fingerprint' => $this->hash($snapshot), 'review' => null, 'actions' => $view['swot']['actions']];
}
/** @param array<array-key,mixed> $input
* @return array<array-key,mixed> */
public function change(User $actor, string $id, array $input): array
{
$control = Validator::make($input, ['opportunity' => 'nullable|string|max:150', 'version' => 'required|integer|min:0', 'operation_key' => 'required|uuid',
'action' => 'required|in:apiex_save,apiex_review,apiex_export,external,swot_save,swot_review,swot_action,swot_restore', 'reason' => 'required|string|min:3|max:4000'])->validate();
return DB::transaction(function () use ($actor, $id, $input, $control) {
$case = $this->desk->case($actor, $id, false, true);
$this->desk->assertOpen($case);
$view = $this->view($actor, $id, $control['opportunity'] ?? null);
$review = in_array($control['action'], ['apiex_review', 'swot_review'], true);
abort_unless($review ? $view['can_review'] : $view['can_write'], 403);
$requestHash = $this->hash([...$input, 'actor' => $actor->id]);
$prior = $this->events($id, $view['opportunity'])->where('correlation_id', $control['operation_key'])->first();
if ($prior) {
abort_unless(hash_equals(json_decode($prior->after_data, true)['request_hash'], $requestHash), 409, 'A tentativa já tem outro conteúdo.');
return [...$this->view($actor, $id, $view['opportunity']), 'replayed' => true];
}
abort_unless((int) $control['version'] === $view['version'], 409, 'Existe outra versão. Os seus campos foram preservados; consulte e compare antes de substituir.');
$state = Arr::only($view, ['version', 'apiex', 'swot']);
$receipt = ['actor' => $actor->id, 'at' => now()->toISOString(), 'reason' => $control['reason']];
switch ($control['action']) {
case 'apiex_save':
$data = Validator::make($input, ['note' => 'present|nullable|string|max:6000', 'additional_documents' => 'present|array|list|max:60', 'additional_documents.*' => ['required', 'distinct', Rule::in(array_column($view['documents'], 'document_id'))]])->validate();
$state['apiex'] = [...$state['apiex'], ...$data, 'review' => null];
break;
case 'apiex_review':
abort_if($view['api_issues'], 422, 'Complete os requisitos e documentos aplicáveis.');
abort_unless(($input['fingerprint'] ?? '') === $view['api_fingerprint'], 409, 'O dossier/documentos mudaram; reveja a versão actual.');
foreach ($view['selected_documents'] as $document) {
$record = DB::table('documents')->where('id', $document['document_id'])->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($record !== null, 404);
app(VerifiedDocumentBinary::class)->respond($record);
}
$state['apiex']['review'] = [...$receipt, 'fingerprint' => $view['api_fingerprint']];
break;
case 'apiex_export':
abort_if($view['api_issues'] || ($state['apiex']['review']['fingerprint'] ?? '') !== $view['api_fingerprint'], 409, 'Reveja a preparação actual antes de exportar.');
$prepared = Arr::only($view, ['case', 'opportunity', 'context', 'reused', 'assessment', 'selected_documents']);
$prepared['apiex'] = Arr::only($state['apiex'], ['note', 'review']);
$state['apiex']['exports'][] = [...$receipt, 'id' => $control['operation_key'], 'fingerprint' => $view['api_fingerprint'], 'context_revision' => $view['context']['revision'], 'documents' => $view['selected_documents'], 'prepared' => $prepared];
break;
case 'external':
$data = Validator::make($input, ['kind' => 'required|in:delivery,clarification,response,decision', 'date' => 'required|date_format:Y-m-d|before_or_equal:today', 'reference' => 'required|string|max:2000',
'proof_document' => ['required', Rule::in(collect($this->rows($view['documents']))->where('available', true)->pluck('document_id')->all())], 'export_id' => 'required|uuid', 'related_id' => 'nullable|uuid', 'outcome' => 'nullable|in:approved,refused,conditional,other'])->validate();
abort_unless(collect($this->rows($state['apiex']['exports']))->contains('id', $data['export_id']), 422, 'Escolha a versão exportada deste dossier/oportunidade.');
$same = collect($this->rows($state['apiex']['external']))->where('export_id', $data['export_id']);
if ($data['kind'] !== 'delivery') {
abort_unless($same->contains('kind', 'delivery'), 422, 'Registe a entrega externa desta versão.');
}
if ($data['kind'] === 'response') {
abort_unless($same->where('kind', 'clarification')->contains('id', $data['related_id'] ?? ''), 422, 'Identifique o pedido de esclarecimento a que responde.');
}
if ($data['kind'] === 'decision') {
abort_unless(! empty($data['outcome']), 422, 'Identifique a decisão efectivamente recebida.');
}
$record = DB::table('documents')->where('id', $data['proof_document'])->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($record !== null, 404);
app(VerifiedDocumentBinary::class)->respond($record);
$state['apiex']['external'][] = [...$data, ...$receipt, 'id' => $control['operation_key'], 'proof_snapshot' => collect($this->rows($view['documents']))->firstWhere('document_id', $data['proof_document'])];
break;
case 'swot_save':
$state['swot'] = $this->draft($input['draft'] ?? [], $view);
break;
case 'swot_review':
$swot = $state['swot'];
abort_unless(($input['fingerprint'] ?? '') === $view['swot_fingerprint'], 409, 'As fontes mudaram; compare antes de rever.');
abort_unless(trim($swot['scope'] ?? '') !== '' && in_array($swot['object_key'], array_column($view['objects'], 'key'), true), 422, 'Identifique objecto e âmbito.');
foreach (['strength', 'weakness', 'opportunity', 'threat'] as $dimension) {
abort_unless(collect($this->rows($swot['rows']))->contains('dimension', $dimension), 422, 'Trate as quatro dimensões; incerteza deve ficar classificada.');
}
foreach ($swot['rows'] as $row) {
abort_unless(trim($row['text'] ?? '') !== '' && trim($row['reference'] ?? '') !== '' && ! empty($row['date']), 422, 'Cada conclusão exige fonte, data e classificação; desconhecido não é ausência.');
}
$state['swot']['source_fingerprint'] = $view['swot_fingerprint'];
$state['swot']['sources_snapshot'] = $this->sourceSnapshot($view['context'], $view['reused'], $view['sources'], $state['swot']['object_key'], $state['swot']['rows']);
$state['swot']['snapshot_fingerprint'] = $view['swot_fingerprint'];
$state['swot']['review'] = [...$receipt, 'fingerprint' => $view['swot_fingerprint'], 'version' => $state['version'] + 1];
break;
case 'swot_action':
abort_if($view['swot_review_required'], 409, 'Reveja a análise e as fontes antes de ligar uma acção.');
$data = Validator::make($input, ['conclusion_id' => ['required', Rule::in(array_column($state['swot']['rows'], 'id'))], 'kind' => 'required|in:idea,opportunity,partners,risk', 'title' => 'required|string|min:3|max:200'])->validate();
$href = $view['links'][$data['kind']];
if ($data['kind'] === 'risk') {
$href = str_replace('#tarefas', (str_contains($href, '?') ? '&' : '?').'swot_action='.$control['operation_key'].'#tarefas', $href);
}
$state['swot']['actions'][] = [...$data, ...$receipt, 'id' => $control['operation_key'], 'case_id' => $id, 'opportunity' => $view['opportunity'], 'review_version' => $state['swot']['review']['version'], 'href' => $href];
break;
case 'swot_restore':
$data = Validator::make($input, ['revision' => 'required|integer|min:1'])->validate();
$event = $this->events($id, $view['opportunity'])->where('after_data->version', $data['revision'])->first();
abort_unless($event !== null, 404);
$old = $this->decode($event)['swot'];
$state['swot'] = [...$old, 'review' => null, 'actions' => $state['swot']['actions']];
break;
}
$state['context_snapshot'] = $view['context'];
$state['reused_snapshot'] = $view['reused'];
$state['version']++;
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'desk_cases', 'entity_id' => $id, 'external_id' => $id.':'.hash('sha256', $view['opportunity'] ?? 'dossier').':'.$state['version'],
'correlation_id' => $control['operation_key'], 'actor_user_id' => $actor->id, 'action' => $control['action'], 'data_classification' => 'restricted',
'before_data' => json_encode(['version' => $view['version']], JSON_THROW_ON_ERROR), 'after_data' => json_encode(['version' => $state['version'], 'scope' => $view['opportunity'] ?? 'dossier', 'request_hash' => $requestHash, 'snapshot' => Crypt::encryptString(json_encode($state, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]);
return [...$this->view($actor, $id, $view['opportunity']), 'replayed' => false];
}, 3);
}
/** Export uses the existing verified binary reader, never a browser-supplied path.
* @return array{bytes:string,export_id:string} */
public function bundle(User $actor, string $id, ?string $opportunity, string $exportId, bool $initialScope = false): array
{
return DB::transaction(function () use ($actor, $id, $opportunity, $exportId, $initialScope) {
$this->desk->case($actor, $id, false, true);
if ($opportunity === null && ! $initialScope) {
$scopes = DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'desk_cases')->where('entity_id', $id)->where('correlation_id', $exportId)->where('action', 'apiex_export')->selectRaw("DISTINCT after_data->>'scope' as scope")->get();
abort_unless($scopes->count() === 1, 422, 'Identifique o âmbito da exportação preservada.');
$scope = $scopes->first()->scope;
$initialScope = $scope === 'dossier';
$opportunity = $initialScope ? null : $scope;
}
$view = $this->view($actor, $id, $opportunity, null, $initialScope);
$export = collect($this->rows($view['apiex']['exports']))->firstWhere('id', $exportId);
abort_unless($export !== null, 404);
$records = [];
foreach ($export['documents'] as $document) {
abort_unless(collect($this->rows($view['documents']))->contains('document_id', $document['document_id']), 403);
$record = DB::table('documents')->where('id', $document['document_id'])->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($record !== null && $record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($document['sha256']), trim($record->sha256)) && hash_equals(trim($record->sha256), trim((string) $record->malware_scanned_sha256)), 404, 'Documento da versão preparada indisponível para descarga; o histórico foi preservado.');
app(VerifiedDocumentBinary::class)->authorizeOpportunityAttachment($record);
$records[$document['document_id']] = $record;
}
$disk = Storage::disk('local');
$key = 'investor-preparations/'.$id.'/'.hash('sha256', $view['opportunity'] ?? 'dossier').'/'.$exportId.'.zip.enc';
if ($disk->exists($key)) {
try {
$sealed = json_decode(Crypt::decryptString($disk->get($key)), true, 512, JSON_THROW_ON_ERROR);
} catch (DecryptException|\JsonException $e) {
abort(404, 'Pacote preservado indisponível; não foi substituído.');
}
abort_unless(($sealed['case_id'] ?? '') === $id && ($sealed['opportunity'] ?? null) === $view['opportunity'] && ($sealed['export_id'] ?? '') === $exportId && ($sealed['fingerprint'] ?? '') === $export['fingerprint'], 404);
$bytes = base64_decode($sealed['binary'] ?? '', true);
abort_unless(is_string($bytes) && hash_equals($sealed['sha256'] ?? '', hash('sha256', $bytes)), 404);
return ['bytes' => $bytes, 'export_id' => $exportId];
}
$prepared = $export['prepared'];
abort_unless(is_array($prepared), 409, 'Snapshot de exportação indisponível; original conservado.');
$file = tempnam(sys_get_temp_dir(), 'investmoz-apiex-');
abort_unless(is_string($file), 503);
$zip = new \ZipArchive;
$open = false;
try {
abort_unless($zip->open($file, \ZipArchive::OVERWRITE) === true, 503);
$open = true;
$manifest = ['format' => 'investmoz-apiex-preparation/1', 'case_id' => $id, 'opportunity' => $view['opportunity'], 'export' => $export, 'context' => $prepared['context'], 'reused' => $prepared['reused'], 'assessment' => $prepared['assessment'], 'notice' => 'Preparação para entrega externa. Não é formulário oficial, submissão electrónica ou aprovação.'];
$index = view('investments.preparation-index', ['workspace' => $prepared, 'manifest' => $manifest])->render();
abort_unless($zip->addFromString('indice.html', $index), 503);
abort_unless($zip->addFromString('manifesto.json', json_encode($manifest, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)), 503);
foreach ($export['documents'] as $n => $document) {
$record = $records[$document['document_id']];
$response = app(VerifiedDocumentBinary::class)->respond($record);
$bytes = $response instanceof BinaryFileResponse ? file_get_contents($response->getFile()->getPathname()) : $response->getContent();
abort_unless(is_string($bytes), 503);
abort_unless($zip->addFromString('anexos/'.sprintf('%02d-', $n + 1).preg_replace('/[^\pL\pN._-]+/u', '-', basename($document['file_name'])), $bytes), 503);
}
abort_unless($zip->close(), 503);
$open = false;
$bytes = file_get_contents($file);
abort_unless(is_string($bytes), 503);
$sealed = ['case_id' => $id, 'opportunity' => $view['opportunity'], 'export_id' => $exportId, 'fingerprint' => $export['fingerprint'], 'sha256' => hash('sha256', $bytes), 'binary' => base64_encode($bytes)];
$temporary = $key.'.'.Str::uuid().'.tmp';
try {
abort_unless($disk->put($temporary, Crypt::encryptString(json_encode($sealed, JSON_THROW_ON_ERROR))) && $disk->move($temporary, $key), 503, 'Não foi possível preservar o pacote; repita a mesma exportação.');
} finally {
$disk->delete($temporary);
}
return ['bytes' => $bytes, 'export_id' => $exportId];
} finally {
if ($open) {
$zip->close();
}
if (is_file($file)) {
unlink($file);
}
}
});
}
}
+12 -4
View File
@@ -160,7 +160,9 @@ final class InvestorPath
$next = ['code' => 'S06', 'title' => $preparation['next'], 'action' => 'Preparar', 'href' => '/investimentos/'.$id.'/contexto/editar#preparation-questions'];
}
return ['private_investment' => $preparation, 'case' => ['id' => $id, 'title' => $case->title, 'version' => $case->version, 'closed' => $case->closed_at !== null],
$preparationTaskKeys = $opportunity !== null && ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(InvestmentPreparationWorkspace::class)->taskKeys($actor, $id, $opportunity) : null;
return ['preparation_action' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(InvestmentPreparationWorkspace::class)->continuation($actor, $id, $opportunity, request()->query('swot_action')) : null, 'private_investment' => $preparation, 'case' => ['id' => $id, 'title' => $case->title, 'version' => $case->version, 'closed' => $case->closed_at !== null],
'journey_id' => $journey->id, 'definition_id' => $version->id, 'definition_version' => $version->version_no, 'source_sha256' => $definition['source_sha256'],
'phases' => $definition['phases'], 'steps' => $steps, 'facts' => $facts, 'fact_reasons' => $factReasons,
'context_opportunity' => $opportunity, 'opportunity_options' => $allChoices,
@@ -174,10 +176,12 @@ final class InvestorPath
'content_sources' => ($owner || $manager) ? ($path['content_sources'] ?? []) : [],
'tasks' => DB::table('tasks')->where('desk_case_id', $id)
->whereNull('deleted_at')
->when($preparationTaskKeys !== null, fn ($q) => $q->where(fn ($p) => $p->whereNull('guidance_key')->orWhere('guidance_key', 'not like', 'swot:%')->orWhereIn('guidance_key', $preparationTaskKeys ?? [])))
->when(! $owner && ! $manager, fn ($q) => $q->where('assignee_user_id', $actor->id))
->get(['id', 'title', 'status', 'version_no', 'due_at', 'assignee_user_id']),
'services' => app(DeskServiceAvailability::class)->current()->get()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'effective_from' => $s->effective_from ?? null]),
'tools' => ['simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '').($opportunity ? '&opportunity='.urlencode($opportunity) : '') : null,
'preparation' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? '/investimentos/'.$id.'/preparacao'.($opportunity ? '?opportunity='.urlencode($opportunity) : '') : null,
'company' => '/investimentos/'.$id.'/forma-juridica',
'desk' => '/balcao-virtual/dossiers/'.$id.($opportunity ? '?opportunity='.urlencode($opportunity) : ''), 'guidance' => '/balcao-virtual/dossiers/'.$id.'/orientacao',
'library' => '/documentos?context='.$id, 'opportunities' => '/oportunidades?context='.$id, 'territory' => '/zonas?context='.$id, 'partners' => '/parcerias/painel?context='.$id,
@@ -337,12 +341,16 @@ final class InvestorPath
public function task(User $actor, string $id, array $input): string
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'code' => ['required', Rule::in(array_map(fn ($n) => sprintf('S%02d', $n), range(1, 19)))],
'preparation_action' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150',
'title' => 'required|string|min:3|max:200', 'due_at' => 'required|date|after:now', 'consent' => 'accepted'])->validate();
return DB::transaction(function () use ($actor, $id, $data): string {
$case = $this->desk->case($actor, $id, true, true);
abort_if($case->closed_at, 409);
$key = 'path:'.$data['code'];
if (!empty($data['preparation_action'])) { app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $data['opportunity'] ?? null, true); }
$origin = !empty($data['preparation_action']) ? app(InvestmentPreparationWorkspace::class)->continuation($actor, $id, $data['opportunity'] ?? null, $data['preparation_action']) : null;
abort_if(!empty($data['preparation_action']) && (!$origin || $origin['kind'] !== 'risk'), 422, 'A acção SWOT não pertence a este dossier/oportunidade ou não é uma acção de risco.');
$key = $origin ? 'swot:'.$origin['id'] : 'path:'.$data['code'];
$existing = DB::table('tasks')->where('desk_case_id', $id)->where('guidance_key', $key)->first();
if ($existing) {
abort_unless($existing->title === $data['title'] && (int) $existing->accepted_by === $actor->id
@@ -351,12 +359,12 @@ final class InvestorPath
return $existing->id;
}
$this->writable($actor, $id, $data['version']);
$step = $this->findStep($this->view($actor, $id), $data['code']);
$step = $this->findStep($this->view($actor, $id, $origin['opportunity'] ?? null), $data['code']);
abort_unless($step !== null && $step['preparation_allowed'], 422);
$task = CrmTask::create(['desk_case_id' => $id, 'guidance_key' => $key, 'title' => $data['title'], 'due_at' => $data['due_at'],
'status' => 'open', 'assignee_user_id' => $actor->id, 'accepted_by' => $actor->id, 'accepted_at' => now(),
'external_sync_status' => 'pending_authorization', 'data_classification' => 'confidential', 'source_system' => 'investor-path']);
$this->desk->event($case, $actor, 'path_task_accepted', ['task_id' => $task->id, 'code' => $data['code'], 'scope' => 'internal_crm']);
$this->desk->event($case, $actor, 'path_task_accepted', ['task_id' => $task->id, 'code' => $data['code'], 'scope' => 'internal_crm', 'preparation_action' => $origin['id'] ?? null, 'opportunity' => $origin['opportunity'] ?? null, 'swot_review_version' => $origin['review_version'] ?? null]);
return $task->id;
});
@@ -1,3 +1,21 @@
## 10/11/2026 — APIEX e SWOT no dossier existente: QA concluído, instalação pendente
**Instalado:** `7476dc343098f15a7982e9d7581bf4f59d43bc1a` em https://teste-investidor.tempo.co.mz, promovido às14:28:12UTC e conferido às14:29:07UTC (16:29 locais). Transporte CBF15 deixa de ser pendência de instalação. Delta14hashes Git/HTTPS, root/up/login/revision200, configuração intacta, zero migrations/importações; backup/restauro190tabelas e369ficheiros conferidos. Recibos `outputs/cbf15-carry-delivery-20261110/`; release anterior e pacotes preservados.
**Implementado em QA, ainda não instalado — APIEX:** dossier existente → requisitos fundamentados disponíveis/em falta/não aplicáveis/indeterminados → documentos existentes e complementos → revisão → prévia/ZIP com índice e anexos íntegros → registo da entrega externa, esclarecimento, resposta e decisão com data/referência/comprovativo. Dados do investidor/empresa/contexto/localização/oportunidade reutilizados. Estados em preparação/pronto para entrega/entrega registada/decisão recebida distintos; exportar não submete nem aprova. Snapshot preparado, versões, SHA e pacote privado cifrado preservados; recuperação devolve os mesmos bytes após alteração do contexto, revalidando acesso e segurança dos documentos. Ausência de recepção electrónica APIEX, segundo a orientação Tempo, não bloqueia este percurso; nenhum conector fictício ou formulário oficial inventado. Reutiliza o avaliador e modelos/fontes aceites; períodos/regimes fora do âmbito actualmente mapeado ficam indeterminados, sem conclusão normativa presumida.
**Implementado em QA, ainda não instalado — SWOT:** no mesmo dossier/oportunidade, objecto explícito investimento/território/sector/cluster, evidências do contexto/análise proactiva e Competitividade pública aprovada, quatro dimensões internas/externas, fonte/data/classificação de cada conclusão (facto documentado/hipótese/por confirmar). Sem pontuação, IA ou inferência de ausência pela falta de registos. Gravação/recuperação/histórico paginado e revisão pela equipa com permissões existentes. Alteração/retirada de fontes assinala revisão, conserva fotografia consultada e edições deliberadas. Acções ideia/oportunidade/parceiros/risco continuam no mesmo dossier; risco liga à tarefa nativa com origem própria e repetição sem duplicação. Privado por defeito; publicação exige o fluxo editorial existente, nunca automática. Contextos retirados ficam no histórico privado de leitura, sem reactivação.
Provas sintéticas no QA55441 existente, com rollback: seis casos API/211asserções;16casos frontend dirigidos (incluindo transporte e cabeçalho comum147páginas), tipos, análise estática e lint aprovados. `outputs/investment-preparation-20261110/verification.json`, ZIP/índice/anexos, recuperação SWOT e provas de isolamento/histórico/segurança. Pacote Git agrupado `outputs/entrega-apiex-swot-20261110.zip`, base exacta7476dc34, reversão de código sem apagar auditoria, pacotes privados ou tarefas posteriores. Nenhuma migration/base/porta/conta real nova, submissão real ou publicação. Compilação QA separada; provas anteriores reutilizadas.
**Saldo concreto:** APIEX e SWOT acima encerrados em implementação QA; instalação deste conjunto e conferência visual continuam pendentes. O conector mantém “The browser access host is unavailable”; SSR/testes não substituem visual, sem novo ciclo de reinícios. Os restantes módulos conservam o saldo anterior, incluindo resíduos L1–L7, restanteL8 e L9–L16. Isto não declara o portal integralmente concluído.
### Utilização — preparação APIEX e SWOT
Abra o dossier autorizado e escolha **Preparação APIEX e SWOT** nas ferramentas/documentos. Se houver várias oportunidades, escolha primeiro o contexto; o título e histórico identificam sempre essa escolha. Em APIEX, confira o resumo reutilizado e use **Alterar** para corrigir no editor existente. Confira os requisitos e os documentos disponíveis, complete os que faltam na biblioteca existente, guarde e reveja. A prévia apresenta o índice; exporte e conserve o ZIP. Registe depois a entrega efectivamente realizada externamente, com data, referência e comprovativo; esclarecimentos/respostas/decisão são eventos separados ligados à exportação. Uma decisão recebida não implica aprovação.
Na aba SWOT, escolha objecto/âmbito, consulte as evidências e preencha as quatro dimensões com fonte/data/classificação. Guarde, peça/recolha a revisão com as permissões actuais e associe uma acção à conclusão. A ligação de risco abre a tarefa no mesmo dossier, sem substituir tarefas anteriores. Histórico permite recuperar versões; restauro de SWOT cria novo rascunho sem eliminar entregas externas ou acções. Se uma gravação tiver resposta incerta, repita a mesma intenção ou consulte e compare a versão guardada; a substituição das edições locais é explícita. Fontes alteradas exigem nova revisão, sem reescrever conclusões. Análises ficam privadas; eventual divulgação passa pelo fluxo editorial existente.
## 10/11/2026 — transporte documental CBF15 em QA
Homologação mantém `d216830b`; este delta ainda não está instalado. Tipos/lint/Pint/buildQA aprovados; pacote com aplicação/reversão exactas,sem migrations/dados/credenciais. Pendência encerrada no recorte normal residente2026/ano civil/MZN: crédito de investimento de origem anterior do mesmo contribuinte/projecto/local/regime, com activo/documento/parcela, exercício de origem, NUIT/projecto de origem, taxa original e qualificação históricas documentados. Exercício de origem anterior a2026,dentro da janela original e não anterior à aquisição; no projecto em funcionamento,a aquisição não precede o início aplicável. Não renova o prazo nem recalcula origem a outra taxa. Valor corrente/ausência e uso efectivo agregado exigem reconciliação explícita; histórico de simulações não equivale a utilização fiscal.
@@ -1,3 +1,15 @@
## 10/11/2026 — APIEX e SWOT no dossier existente: QA concluído, instalação pendente
**Instalado:** `7476dc343098f15a7982e9d7581bf4f59d43bc1a` em https://teste-investidor.tempo.co.mz, promovido às14:28:12UTC e conferido às14:29:07UTC (16:29 locais). Transporte CBF15 deixa de ser pendência de instalação. Delta14hashes Git/HTTPS, root/up/login/revision200, configuração intacta, zero migrations/importações; backup/restauro190tabelas e369ficheiros conferidos. Recibos `outputs/cbf15-carry-delivery-20261110/`; release anterior e pacotes preservados.
**Implementado em QA, ainda não instalado — APIEX:** dossier existente → requisitos fundamentados disponíveis/em falta/não aplicáveis/indeterminados → documentos existentes e complementos → revisão → prévia/ZIP com índice e anexos íntegros → registo da entrega externa, esclarecimento, resposta e decisão com data/referência/comprovativo. Dados do investidor/empresa/contexto/localização/oportunidade reutilizados. Estados em preparação/pronto para entrega/entrega registada/decisão recebida distintos; exportar não submete nem aprova. Snapshot preparado, versões, SHA e pacote privado cifrado preservados; recuperação devolve os mesmos bytes após alteração do contexto, revalidando acesso e segurança dos documentos. Ausência de recepção electrónica APIEX, segundo a orientação Tempo, não bloqueia este percurso; nenhum conector fictício ou formulário oficial inventado. Reutiliza o avaliador e modelos/fontes aceites; períodos/regimes fora do âmbito actualmente mapeado ficam indeterminados, sem conclusão normativa presumida.
**Implementado em QA, ainda não instalado — SWOT:** no mesmo dossier/oportunidade, objecto explícito investimento/território/sector/cluster, evidências do contexto/análise proactiva e Competitividade pública aprovada, quatro dimensões internas/externas, fonte/data/classificação de cada conclusão (facto documentado/hipótese/por confirmar). Sem pontuação, IA ou inferência de ausência pela falta de registos. Gravação/recuperação/histórico paginado e revisão pela equipa com permissões existentes. Alteração/retirada de fontes assinala revisão, conserva fotografia consultada e edições deliberadas. Acções ideia/oportunidade/parceiros/risco continuam no mesmo dossier; risco liga à tarefa nativa com origem própria e repetição sem duplicação. Privado por defeito; publicação exige o fluxo editorial existente, nunca automática. Contextos retirados ficam no histórico privado de leitura, sem reactivação.
Provas sintéticas no QA55441 existente, com rollback: seis casos API/211asserções;16casos frontend dirigidos (incluindo transporte e cabeçalho comum147páginas), tipos, análise estática e lint aprovados. `outputs/investment-preparation-20261110/verification.json`, ZIP/índice/anexos, recuperação SWOT e provas de isolamento/histórico/segurança. Pacote Git agrupado `outputs/entrega-apiex-swot-20261110.zip`, base exacta7476dc34, reversão de código sem apagar auditoria, pacotes privados ou tarefas posteriores. Nenhuma migration/base/porta/conta real nova, submissão real ou publicação. Compilação QA separada; provas anteriores reutilizadas.
**Saldo concreto:** APIEX e SWOT acima encerrados em implementação QA; instalação deste conjunto e conferência visual continuam pendentes. O conector mantém “The browser access host is unavailable”; SSR/testes não substituem visual, sem novo ciclo de reinícios. Os restantes módulos conservam o saldo anterior, incluindo resíduos L1–L7, restanteL8 e L9–L16. Isto não declara o portal integralmente concluído.
## 10/11/2026 — transporte documental CBF15 em QA
Homologação mantém `d216830b`; este delta ainda não está instalado. Tipos/lint/Pint/buildQA aprovados; pacote com aplicação/reversão exactas,sem migrations/dados/credenciais. Pendência encerrada no recorte normal residente2026/ano civil/MZN: crédito de investimento de origem anterior do mesmo contribuinte/projecto/local/regime, com activo/documento/parcela, exercício de origem, NUIT/projecto de origem, taxa original e qualificação históricas documentados. Exercício de origem anterior a2026,dentro da janela original e não anterior à aquisição; no projecto em funcionamento,a aquisição não precede o início aplicável. Não renova o prazo nem recalcula origem a outra taxa. Valor corrente/ausência e uso efectivo agregado exigem reconciliação explícita; histórico de simulações não equivale a utilização fiscal.
File diff suppressed because one or more lines are too long
@@ -1,3 +1,15 @@
## 10/11/2026 — APIEX e SWOT no dossier existente: QA concluído, instalação pendente
**Instalado:** `7476dc343098f15a7982e9d7581bf4f59d43bc1a` em https://teste-investidor.tempo.co.mz, promovido às14:28:12UTC e conferido às14:29:07UTC (16:29 locais). Transporte CBF15 deixa de ser pendência de instalação. Delta14hashes Git/HTTPS, root/up/login/revision200, configuração intacta, zero migrations/importações; backup/restauro190tabelas e369ficheiros conferidos. Recibos `outputs/cbf15-carry-delivery-20261110/`; release anterior e pacotes preservados.
**Implementado em QA, ainda não instalado — APIEX:** dossier existente → requisitos fundamentados disponíveis/em falta/não aplicáveis/indeterminados → documentos existentes e complementos → revisão → prévia/ZIP com índice e anexos íntegros → registo da entrega externa, esclarecimento, resposta e decisão com data/referência/comprovativo. Dados do investidor/empresa/contexto/localização/oportunidade reutilizados. Estados em preparação/pronto para entrega/entrega registada/decisão recebida distintos; exportar não submete nem aprova. Snapshot preparado, versões, SHA e pacote privado cifrado preservados; recuperação devolve os mesmos bytes após alteração do contexto, revalidando acesso e segurança dos documentos. Ausência de recepção electrónica APIEX, segundo a orientação Tempo, não bloqueia este percurso; nenhum conector fictício ou formulário oficial inventado. Reutiliza o avaliador e modelos/fontes aceites; períodos/regimes fora do âmbito actualmente mapeado ficam indeterminados, sem conclusão normativa presumida.
**Implementado em QA, ainda não instalado — SWOT:** no mesmo dossier/oportunidade, objecto explícito investimento/território/sector/cluster, evidências do contexto/análise proactiva e Competitividade pública aprovada, quatro dimensões internas/externas, fonte/data/classificação de cada conclusão (facto documentado/hipótese/por confirmar). Sem pontuação, IA ou inferência de ausência pela falta de registos. Gravação/recuperação/histórico paginado e revisão pela equipa com permissões existentes. Alteração/retirada de fontes assinala revisão, conserva fotografia consultada e edições deliberadas. Acções ideia/oportunidade/parceiros/risco continuam no mesmo dossier; risco liga à tarefa nativa com origem própria e repetição sem duplicação. Privado por defeito; publicação exige o fluxo editorial existente, nunca automática. Contextos retirados ficam no histórico privado de leitura, sem reactivação.
Provas sintéticas no QA55441 existente, com rollback: seis casos API/211asserções;16casos frontend dirigidos (incluindo transporte e cabeçalho comum147páginas), tipos, análise estática e lint aprovados. `outputs/investment-preparation-20261110/verification.json`, ZIP/índice/anexos, recuperação SWOT e provas de isolamento/histórico/segurança. Pacote Git agrupado `outputs/entrega-apiex-swot-20261110.zip`, base exacta7476dc34, reversão de código sem apagar auditoria, pacotes privados ou tarefas posteriores. Nenhuma migration/base/porta/conta real nova, submissão real ou publicação. Compilação QA separada; provas anteriores reutilizadas.
**Saldo concreto:** APIEX e SWOT acima encerrados em implementação QA; instalação deste conjunto e conferência visual continuam pendentes. O conector mantém “The browser access host is unavailable”; SSR/testes não substituem visual, sem novo ciclo de reinícios. Os restantes módulos conservam o saldo anterior, incluindo resíduos L1–L7, restanteL8 e L9–L16. Isto não declara o portal integralmente concluído.
## 10/11/2026 — transporte documental CBF15 em QA
Homologação mantém `d216830b`; este delta ainda não está instalado. Tipos/lint/Pint/buildQA aprovados; pacote com aplicação/reversão exactas,sem migrations/dados/credenciais. Pendência encerrada no recorte normal residente2026/ano civil/MZN: crédito de investimento de origem anterior do mesmo contribuinte/projecto/local/regime, com activo/documento/parcela, exercício de origem, NUIT/projecto de origem, taxa original e qualificação históricas documentados. Exercício de origem anterior a2026,dentro da janela original e não anterior à aquisição; no projecto em funcionamento,a aquisição não precede o início aplicável. Não renova o prazo nem recalcula origem a outra taxa. Valor corrente/ausência e uso efectivo agregado exigem reconciliação explícita; histórico de simulações não equivale a utilização fiscal.
+143
View File
@@ -0,0 +1,143 @@
import { createOperationKey } from './operation-key.ts';
export const swotDimensions = {
strength: 'Forças — internas ao objecto',
weakness: 'Fraquezas — internas ao objecto',
opportunity: 'Oportunidades — externas ao objecto',
threat: 'Ameaças — externas ao objecto',
} as const;
export type SwotRow = {
id: string;
dimension: keyof typeof swotDimensions;
text: string;
reference: string;
source_key: string | null;
date: string | null;
classification: 'documented_fact' | 'hypothesis' | 'unconfirmed';
};
export type SwotDraft = {
sources_snapshot?: unknown;
snapshot_fingerprint?: string;
object_key: string;
scope: string | null;
rows: SwotRow[];
source_fingerprint: string;
review?: { reason: string; at: string } | null;
actions: {
id: string;
title: string;
kind: string;
href: string;
review_version: number;
}[];
};
export type Workspace = {
historical?: boolean;
initial_scope?: boolean;
saved_scopes?: { key: string; label: string; href: string }[];
case: { id: string; title: string; closed: boolean };
opportunity: string | null;
opportunity_label?: string | null;
version: number;
apiex: {
note: string | null;
additional_documents: string[];
review: { reason: string; at: string; fingerprint: string } | null;
exports: { id: string; at: string; context_revision: number }[];
external: {
id: string;
kind: string;
date: string;
reference: string;
proof_document: string;
related_id?: string;
export_id: string;
outcome?: string;
}[];
};
swot: SwotDraft;
api_state: string;
api_issues: string[];
api_fingerprint: string;
swot_fingerprint: string;
swot_review_required: boolean;
can_write: boolean;
can_review: boolean;
links: Record<string, string>;
objects: { key: string; label: string; kind: string }[];
sources: {
key: string;
label: string;
consulted?: unknown;
current?: unknown;
notice?: string;
}[];
reused?: Record<string, unknown>;
context: { revision: number; fields: Record<string, unknown> };
assessment: {
requirements: {
key: string;
label: string;
state: string;
required: boolean;
available: boolean;
reason: string;
document_id: string | null;
source: { reference: string; url: string };
}[];
};
documents: {
id: string;
document_id: string;
file_name: string;
available: boolean;
version_no: number;
}[];
history_next?: number | null;
history: {
version: number;
action: string;
at: string;
actor: number;
operation_key?: string;
}[];
};
/** A lost response retains the exact intent; editing fields never mutates a retry. */
export class PreparationAttempt {
private value: Record<string, unknown> | null = null;
start(
version: number,
opportunity: string | null,
action: string,
data: Record<string, unknown>,
reason: string,
) {
if (this.value)
throw Error('Resolva a tentativa anterior antes de iniciar outra.');
this.value = JSON.parse(
JSON.stringify({
...data,
version,
opportunity,
action,
reason,
operation_key: createOperationKey(),
}),
) as Record<string, unknown>;
return this.current()!;
}
current() {
return this.value ? structuredClone(this.value) : null;
}
complete() {
this.value = null;
}
canAdopt(view: Pick<Workspace, 'version' | 'history'>) {
return (
!this.value ||
view.version > Number(this.value.version) ||
view.history.some(
(h) => h.operation_key === this.value?.operation_key,
)
);
}
}
+2
View File
@@ -3,6 +3,7 @@ export class ProfileValidationError extends Error {
message: string,
public readonly fields: Record<string, string>,
public readonly reauthenticate = false,
public readonly status: number | null = null,
) {
super(message);
}
@@ -67,6 +68,7 @@ export async function profileJson(
]),
),
reauthenticate,
response.status,
);
return result;
}
File diff suppressed because it is too large Load Diff
+67 -11
View File
@@ -39,6 +39,13 @@ type Step = {
source_paragraph: number;
};
type Path = {
preparation_action?: {
id: string;
title: string;
kind: string;
case_id: string;
opportunity: string | null;
} | null;
context_opportunity: string | null;
opportunity_options: NonNullable<DossierContext['opportunity_choices']>;
private_investment?: Preparation | null;
@@ -193,7 +200,11 @@ function Stage({ step, path }: { step: Step; path: Path }) {
className={`space-y-3 rounded-xl border p-4 ${licensing ? 'md:col-span-2' : ''}`}
>
<Heading
className={licensing ? 'text-lg font-semibold' : 'cursor-pointer font-semibold'}
className={
licensing
? 'text-lg font-semibold'
: 'cursor-pointer font-semibold'
}
>
{step.title} · {pathStates[step.state]}
</Heading>
@@ -270,7 +281,11 @@ function Stage({ step, path }: { step: Step; path: Path }) {
</p>
))}
<Reference className="text-xs">
<ReferenceHeading className={licensing ? 'font-semibold' : 'cursor-pointer underline'}>
<ReferenceHeading
className={
licensing ? 'font-semibold' : 'cursor-pointer underline'
}
>
Fonte e referência desta etapa
</ReferenceHeading>
<p>
@@ -285,9 +300,17 @@ function Stage({ step, path }: { step: Step; path: Path }) {
</Reference>
{step.preparation_allowed && (path.canWrite || path.canReview) && (
<Record
aria-label={licensing ? 'Registar licenciamento' : undefined}
aria-label={
licensing ? 'Registar licenciamento' : undefined
}
>
<RecordHeading className={licensing ? 'font-semibold' : 'cursor-pointer underline'}>
<RecordHeading
className={
licensing
? 'font-semibold'
: 'cursor-pointer underline'
}
>
Registar avanço ou pendência
</RecordHeading>
<form
@@ -437,9 +460,11 @@ function Stage({ step, path }: { step: Step; path: Path }) {
))}
<button
disabled={form.processing}
className={licensing
? 'rounded bg-emerald-800 px-4 py-3 font-semibold text-white disabled:opacity-50'
: 'rounded border p-2'}
className={
licensing
? 'rounded bg-emerald-800 px-4 py-3 font-semibold text-white disabled:opacity-50'
: 'rounded border p-2'
}
>
Guardar registo
</button>
@@ -456,7 +481,11 @@ export default function InvestmentPath(path: Path) {
useEffect(() => {
const reveal = () => {
if (root.current) {
revealPathStage(root.current, window.location.hash, stageCodes.split(','));
revealPathStage(
root.current,
window.location.hash,
stageCodes.split(','),
);
}
};
reveal();
@@ -467,6 +496,15 @@ export default function InvestmentPath(path: Path) {
auth: { user: { id: number } };
flash: { success?: string };
}>().props;
useEffect(() => {
if (path.preparation_action && window.location.hash === '#tarefas') {
const target = document.getElementById('tarefas');
if (target instanceof HTMLDetailsElement) {
target.open = true;
target.scrollIntoView({ block: 'start' });
}
}
}, [path.preparation_action]);
const facts = useForm({
version: path.case.version,
context_opportunity: path.context_opportunity,
@@ -476,7 +514,9 @@ export default function InvestmentPath(path: Path) {
const task = useForm({
version: path.case.version,
code: 'S02',
title: '',
title: path.preparation_action?.title ?? '',
preparation_action: path.preparation_action?.id ?? null,
opportunity: path.preparation_action?.opportunity ?? null,
due_at: '',
consent: false,
});
@@ -498,7 +538,10 @@ export default function InvestmentPath(path: Path) {
);
}
return (
<main ref={root} className="mx-auto max-w-6xl min-w-0 space-y-6 p-4 [overflow-wrap:anywhere] md:p-8 [&_input]:max-w-full [&_input]:min-w-0 [&_label]:min-w-0 [&_select]:max-w-full [&_select]:min-w-0">
<main
ref={root}
className="mx-auto max-w-6xl min-w-0 space-y-6 p-4 [overflow-wrap:anywhere] md:p-8 [&_input]:max-w-full [&_input]:min-w-0 [&_label]:min-w-0 [&_select]:max-w-full [&_select]:min-w-0"
>
<Head title={path.case.title} />
<Link className="underline" href="/investimentos">
Os meus investimentos
@@ -732,6 +775,7 @@ export default function InvestmentPath(path: Path) {
{
(
{
preparation: 'Preparação APIEX e SWOT',
simulator: 'Plano e simulador',
desk: 'Balcão e histórico',
library: 'Biblioteca',
@@ -975,6 +1019,11 @@ export default function InvestmentPath(path: Path) {
>
Preparar os dados para a constituição da empresa
</Link>
{path.tools.preparation && (
<Link className="underline" href={path.tools.preparation}>
Preparação APIEX e análise SWOT deste investimento
</Link>
)}
{path.tools.company && (
<Link className="underline" href={path.tools.company}>
Comparar formas jurídicas para este investimento
@@ -997,10 +1046,17 @@ export default function InvestmentPath(path: Path) {
Anexar ou conferir no Balcão
</Link>
</section>
<details className="space-y-3 rounded-xl border p-4">
<details id="tarefas" className="space-y-3 rounded-xl border p-4">
<summary className="cursor-pointer text-lg font-semibold">
Acompanhamento interno
</summary>
{path.preparation_action && (
<p role="status">
Acção ligada à SWOT deste dossier:{' '}
{path.preparation_action.title}. Confira a etapa e a
data antes de assumir a tarefa.
</p>
)}
{path.canWrite && (
<form
className="grid min-w-0 gap-3 [&_input]:max-w-full [&_input]:min-w-0 [&_label]:min-w-0 [&_select]:max-w-full [&_select]:min-w-0"
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Preparação documental — {{ $workspace['case']['title'] }}</title><style>body{font:16px sans-serif;max-width:70rem;margin:2rem auto;padding:1rem;color:#17202a}table{border-collapse:collapse;width:100%}td,th{padding:.6rem;border:1px solid #aaa;text-align:left;vertical-align:top}pre{white-space:pre-wrap;overflow-wrap:anywhere}h1,h2{break-after:avoid}</style></head><body>
<h1>Preparação documental APIEX</h1>
<p>{{ $workspace['case']['title'] }} · Dossier {{ $workspace['case']['id'] }} · Oportunidade {{ $workspace['opportunity'] ?? 'contexto único do dossier' }} · Revisão de contexto {{ $workspace['context']['revision'] }}</p>
<p>Preparação para entrega externa. Não é formulário oficial, submissão electrónica nem aprovação. Os modelos existentes só integram o pacote quando associados como documentos. Não foi criada uma interface de recepção APIEX.</p>
<h2>Índice e requisitos aplicáveis</h2><table><thead><tr><th>Requisito</th><th>Situação</th><th>Fundamento</th><th>Documento/versão</th></tr></thead><tbody>
@foreach($workspace['assessment']['requirements'] as $requirement)
<tr><td>{{ $requirement['label'] }}</td><td>{{ $requirement['state'] === 'not_applicable' ? 'Não aplicável' : ($requirement['available'] ? 'Disponível' : ($requirement['state'] === 'indeterminate' ? 'Por determinar' : 'Em falta/indisponível')) }}</td><td>{{ $requirement['reason'] }} {{ $requirement['source']['reference'] }} · {{ $requirement['source']['url'] }}</td><td>{{ $requirement['document']['file_name'] ?? '—' }} · {{ $requirement['document']['version_no'] ?? '—' }}</td></tr>
@endforeach
</tbody></table>
<h2>Anexos organizados</h2><ol>
@foreach($workspace['selected_documents'] as $n => $document)
<li>@if($manifest)<a href="{{ 'anexos/'.sprintf('%02d-', $n + 1).preg_replace('/[^\pL\pN._-]+/u', '-', basename($document['file_name'])) }}">{{ $document['file_name'] }}</a>@else{{ $document['file_name'] }}@endif · versão {{ $document['version_no'] }} · SHA256 {{ $document['sha256'] }}</li>
@endforeach
</ol>
<h2>Investidor e empresa reutilizados</h2><pre>{{ json_encode($workspace['reused'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre><h2>Dados reutilizados do contexto</h2><pre>{{ json_encode($workspace['context']['fields'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
<p>{{ $workspace['apiex']['note'] }}</p>
<h2>Revisão</h2><p>{{ $workspace['apiex']['review']['reason'] ?? 'Por conferir' }} · {{ $workspace['apiex']['review']['at'] ?? '' }}</p>
@if($manifest)<h2>Identificação da exportação</h2><p>{{ $manifest['export']['id'] }} · {{ $manifest['export']['at'] }} · {{ $manifest['export']['fingerprint'] }}</p>@endif
</body></html>
+6
View File
@@ -4,6 +4,7 @@ use App\Http\Controllers\CompanyComparisonController;
use App\Http\Controllers\CompanyPreparationController;
use App\Http\Controllers\InvestmentComplementController;
use App\Http\Controllers\InvestmentGuideController;
use App\Http\Controllers\InvestmentPreparationController;
use App\Http\Controllers\InvestorContextController;
use App\Http\Controllers\InvestorPathController as Path;
use App\Http\Controllers\LegacyContentController;
@@ -22,6 +23,11 @@ Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class, Virtual
Route::put('/contexto-atual', [InvestorContextController::class, 'focus']);
Route::get('/fontes-contexto', [InvestorContextController::class, 'options']);
Route::get('/opcoes-corredor', [InvestorContextController::class, 'corridorOptions']);
Route::get('/{id}/preparacao', [InvestmentPreparationController::class, 'show'])->whereUuid('id');
Route::post('/{id}/preparacao', [InvestmentPreparationController::class, 'change'])->whereUuid('id');
Route::get('/{id}/preparacao/previa', [InvestmentPreparationController::class, 'preview'])->whereUuid('id');
Route::get('/{id}/preparacao/exportar/{exportId}', [InvestmentPreparationController::class, 'download'])->whereUuid('id')->whereUuid('exportId');
Route::post('/{id}/preparacao/exportar', [InvestmentPreparationController::class, 'export'])->whereUuid('id');
Route::get('/{id}/forma-juridica', [CompanyComparisonController::class, 'show'])->whereUuid('id');
Route::post('/{id}/forma-juridica', [CompanyComparisonController::class, 'save'])->whereUuid('id');
Route::get('/{id}/preparar-empresa', [CompanyPreparationController::class, 'show'])->whereUuid('id');
@@ -0,0 +1,302 @@
<?php
namespace Tests\Feature;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\ClusterCompetitiveness;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentCorridorContext;
use App\Services\InvestmentPreparationWorkspace;
use App\Services\PrivateInvestmentPreparation;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class InvestmentPreparationWorkspaceTest extends TestCase
{
use DatabaseTransactions;
private User $owner;
private string $caseId;
private array $documents = [];
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$db = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $db->name);
$this->assertSame(55441, (int) $db->port);
config(['investor-path.enabled' => true, 'investor-path.private_preparation' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
Http::preventStrayRequests();
Storage::fake('local');
$this->owner = User::factory()->create();
$this->actingAs($this->owner);
$this->caseId = $this->postJson('/investimentos', ['title' => 'SYNTHETIC private APIEX and SWOT QA', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
foreach (PrivateInvestmentPreparation::DOCUMENTS as $key) {
$id = (string) Str::uuid();
$bytes = 'SYNTHETIC QA ONLY '.$key;
$sha = hash('sha256', $bytes);
Storage::disk('local')->put('qa-preparation/'.$id, $bytes);
DB::table('documents')->insert(['id' => $id, 'file_name' => $key.'.txt', 'media_type' => 'text/plain', 'storage_key' => 'qa-preparation/'.$id, 'sha256' => $sha, 'malware_scanned_sha256' => $sha, 'malware_scan_status' => 'clean', 'status' => 'active', 'uploaded_by' => $this->owner->id]);
DB::table('desk_documents')->insert(['id' => (string) Str::uuid(), 'case_id' => $this->caseId, 'document_id' => $id, 'category' => 'support', 'source_reference' => 'SYNTHETIC fixture, not a real scan/submission', 'linked_by' => $this->owner->id]);
$this->documents[$key] = $id;
}
$this->context(['investment_form' => 'direct', 'investment_preparation' => 'yes', 'investment_activity_scope' => 'general', 'implementation_mode' => 'domestic_entity', 'capital_origin' => 'national', 'site_tenure' => 'lease', 'project_concession' => 'no', 'significant_implications' => 'no', 'land_extension' => 'no', 'forest_concession' => 'no', 'total_investment' => '1000000', 'currency' => 'MZN', 'expected_start_date' => '2026-01-01', 'entity_stage' => 'being_formed'], $this->documents);
}
private function context(array $declarations, array $links = [], array $selection = []): void
{
$view = $this->getJson('/investimentos/'.$this->caseId.'/contexto')->assertOk()->json();
$data = ['version' => $view['case']['version'], 'selection' => $selection, 'declarations' => $declarations, 'document_links' => $links];
$plan = $this->postJson('/investimentos/'.$this->caseId.'/contexto/previa', $data)->assertOk()->json();
$this->postJson('/investimentos/'.$this->caseId.'/contexto/confirmar', [...$data, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
}
private function workspace(): array
{
return $this->getJson('/investimentos/'.$this->caseId.'/preparacao')->assertOk()->assertHeader('Cache-Control', 'no-store, private')->json();
}
private function action(array $view, string $action, array $data = []): array
{
return $this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['version' => $view['version'], 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC explicit review, not real external action', 'action' => $action, ...$data])->assertOk()->json();
}
public function test_apiex_existing_dossier_requirements_review_zip_and_external_delivery_without_submission_or_approval(): void
{
$before = DB::table('journey_steps')->orderBy('id')->get()->toJson();
$view = $this->workspace();
$this->assertSame([], $view['api_issues']);
$this->assertSame('preparing', $view['api_state']);
$view = $this->action($view, 'apiex_review', ['fingerprint' => $view['api_fingerprint']]);
$this->assertSame('ready', $view['api_state']);
$this->get('/investimentos/'.$this->caseId.'/preparacao/previa')->assertOk()->assertSee('Não é formulário oficial', false);
$export = (string) Str::uuid();
$body = ['version' => $view['version'], 'operation_key' => $export, 'reason' => 'SYNTHETIC download prepared package'];
$download = $this->postJson('/investimentos/'.$this->caseId.'/preparacao/exportar', $body)->assertOk()->assertHeader('Content-Type', 'application/zip')->assertHeader('X-Export-Id', $export);
$file = tempnam(sys_get_temp_dir(), 'qa-apiex-');
file_put_contents($file, $download->getContent());
$zip = new \ZipArchive;
try {
$this->assertTrue($zip->open($file));
$manifest = json_decode($zip->getFromName('manifesto.json'), true, flags: JSON_THROW_ON_ERROR);
$this->assertSame($this->caseId, $manifest['case_id']);
$this->assertSame($export, $manifest['export']['id']);
$this->assertSame($this->owner->email, $manifest['reused']['investor']['email']);
$this->assertSame($manifest['export']['prepared']['context'], $manifest['context']);
$this->assertStringContainsString('Índice', $zip->getFromName('indice.html'));
foreach ($manifest['export']['documents'] as $n => $doc) {
$this->assertSame($doc['sha256'], hash('sha256', $zip->getFromName('anexos/'.sprintf('%02d-', $n + 1).$doc['file_name'])));
}
$zip->close();
} finally {
unlink($file);
}
$this->postJson('/investimentos/'.$this->caseId.'/preparacao/exportar', $body)->assertOk();
$this->context(['project_summary' => 'SYNTHETIC later context, old archive must remain exact']);
$oldPackage = $this->get('/investimentos/'.$this->caseId.'/preparacao/exportar/'.$export)->assertOk();
$this->assertSame(hash('sha256', $download->getContent()), hash('sha256', $oldPackage->getContent()));
$view = $this->workspace();
$this->assertCount(1, $view['apiex']['exports']);
$this->assertSame('preparing', $view['api_state']);
$before = DB::table('journey_steps')->orderBy('id')->get()->toJson();
foreach (['delivery', 'clarification', 'response', 'decision'] as $kind) {
$related = collect($view['apiex']['external'])->firstWhere('kind', 'clarification')['id'] ?? null;
$view = $this->action($view, 'external', ['kind' => $kind, 'date' => now()->toDateString(), 'reference' => 'SYNTHETIC '.$kind, 'proof_document' => $this->documents['identity'], 'export_id' => $export, 'related_id' => $related, 'outcome' => $kind === 'decision' ? 'conditional' : null]);
$this->assertSame($kind === 'decision' ? 'decision_received' : 'delivery_recorded', $view['api_state']);
}
$out = base_path('outputs/investment-preparation-20261110');
if (! is_dir($out)) {
mkdir($out, 0700, true);
}
file_put_contents($out.'/apiex-package.zip', $download->getContent());
file_put_contents($out.'/apiex-recovered.json', json_encode([...$view, '_qa_archive' => ['initial_sha256' => hash('sha256', $download->getContent()), 'after_context_change_sha256' => hash('sha256', $oldPackage->getContent())]], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
$this->assertSame($before, DB::table('journey_steps')->orderBy('id')->get()->toJson());
$this->assertCount(4, $view['apiex']['external']);
Http::assertNothingSent();
DB::table('desk_cases')->where('id', $this->caseId)->update(['closed_at' => now()]);
$this->get('/investimentos/'.$this->caseId.'/preparacao/exportar/'.$export)->assertOk()->assertHeader('Content-Type', 'application/zip');
$this->assertSame($this->documents['identity'], $view['apiex']['external'][0]['proof_snapshot']['document_id']);
$this->assertStringNotContainsString('SYNTHETIC delivery', DB::table('audit_events')->where('source_system', 'investor-preparation')->get(['after_data'])->toJson());
}
public function test_swot_context_evidence_four_dimensions_review_action_recovery_and_source_change_preserve_deliberate_edits(): void
{
$view = $this->workspace();
$rows = [];
foreach (['strength', 'weakness', 'opportunity', 'threat'] as $dimension) {
$rows[] = ['id' => (string) Str::uuid(), 'dimension' => $dimension, 'text' => 'SYNTHETIC deliberate '.$dimension, 'reference' => 'SYNTHETIC source, no economic conclusion', 'source_key' => null, 'date' => now()->toDateString(), 'classification' => 'unconfirmed'];
}
$view = $this->action($view, 'swot_save', ['draft' => ['object_key' => 'investment:'.$this->caseId, 'scope' => 'SYNTHETIC exact investment scope', 'rows' => $rows, 'source_fingerprint' => $view['swot_fingerprint']]]);
$saved = $view['version'];
$view = $this->action($view, 'swot_review', ['fingerprint' => $view['swot_fingerprint']]);
$this->assertFalse($view['swot_review_required']);
$view = $this->action($view, 'swot_action', ['conclusion_id' => $rows[3]['id'], 'kind' => 'risk', 'title' => 'SYNTHETIC investigate evidence gap']);
$this->assertSame($this->caseId, $view['swot']['actions'][0]['case_id']);
$this->assertStringContainsString('/investimentos/'.$this->caseId, $view['swot']['actions'][0]['href']);
$continued = $this->getJson($view['swot']['actions'][0]['href'])->assertOk()->assertJsonPath('preparation_action.id', $view['swot']['actions'][0]['id'])->json();
$legacy = $this->postJson('/investimentos/'.$this->caseId.'/tarefas', ['version' => $continued['case']['version'], 'code' => 'S01', 'title' => 'SYNTHETIC independent existing stage task', 'due_at' => now()->addDay()->startOfMinute()->toISOString(), 'consent' => true])->assertCreated()->json('id');
$taskBody = ['version' => $continued['case']['version'], 'code' => 'S01', 'title' => $view['swot']['actions'][0]['title'], 'due_at' => now()->addDay()->startOfMinute()->toISOString(), 'consent' => true, 'preparation_action' => $view['swot']['actions'][0]['id'], 'opportunity' => $view['opportunity']];
$task = $this->postJson('/investimentos/'.$this->caseId.'/tarefas', $taskBody)->assertCreated()->json('id');
$this->postJson('/investimentos/'.$this->caseId.'/tarefas', $taskBody)->assertCreated()->assertJsonPath('id', $task);
$this->assertSame('swot:'.$view['swot']['actions'][0]['id'], DB::table('tasks')->where('id', $task)->value('guidance_key'));
$this->assertSame('SYNTHETIC independent existing stage task', DB::table('tasks')->where('id', $legacy)->value('title'));
$this->assertSame($this->caseId, DB::table('tasks')->where('id', $task)->value('desk_case_id'));
$this->assertSame($view['swot']['actions'][0]['title'], DB::table('tasks')->where('id', $task)->value('title'));
$this->context(['project_summary' => 'SYNTHETIC changed source context']);
$back = $this->workspace();
$this->assertTrue($back['swot_review_required']);
$this->assertSame($rows, $back['swot']['rows']);
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['action' => 'swot_action', 'reason' => 'SYNTHETIC stale review', 'version' => $back['version'], 'operation_key' => (string) Str::uuid(), 'conclusion_id' => $rows[0]['id'], 'kind' => 'idea', 'title' => 'Should not advance'])->assertConflict();
$back = $this->action($back, 'swot_restore', ['revision' => $saved]);
$this->assertTrue($back['swot_review_required']);
$this->assertSame($rows, $back['swot']['rows']);
$this->assertCount(1, $back['swot']['actions']);
$this->assertGreaterThan($saved, $back['version']);
file_put_contents(base_path('outputs/investment-preparation-20261110/swot-recovered.json'), json_encode([...$back, '_qa_native_task' => ['id' => $task, 'case_id' => DB::table('tasks')->where('id', $task)->value('desk_case_id'), 'title' => DB::table('tasks')->where('id', $task)->value('title'), 'origin_key' => DB::table('tasks')->where('id', $task)->value('guidance_key')]], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
public function test_access_stale_updates_replay_and_scan_changes_do_not_bypass_existing_dossier_guards(): void
{
$view = $this->workspace();
$body = ['version' => $view['version'], 'action' => 'apiex_save', 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC preservation', 'additional_documents' => [], 'note' => 'SYNTHETIC deliberate note'];
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', $body)->assertOk()->assertJsonPath('replayed', false);
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', $body)->assertOk()->assertJsonPath('replayed', true);
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', [...$body, 'note' => 'OTHER'])->assertConflict();
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', [...$body, 'operation_key' => (string) Str::uuid()])->assertConflict();
$view = $this->workspace();
$view = $this->action($view, 'apiex_review', ['fingerprint' => $view['api_fingerprint']]);
$export = (string) Str::uuid();
$this->postJson('/investimentos/'.$this->caseId.'/preparacao/exportar', ['version' => $view['version'], 'operation_key' => $export, 'reason' => 'SYNTHETIC archive security boundary'])->assertOk();
DB::table('documents')->where('id', $this->documents['identity'])->update(['malware_scan_status' => 'pending']);
$blocked = $this->get('/investimentos/'.$this->caseId.'/preparacao/exportar/'.$export)->assertNotFound();
file_put_contents(base_path('outputs/investment-preparation-20261110/archive-security.json'), json_encode(['synthetic' => true, 'revoked_scan_cached_download_status' => $blocked->getStatusCode(), 'encrypted_archive_count' => count(Storage::disk('local')->allFiles('investor-preparations/'.$this->caseId))], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
$back = $this->workspace();
$this->assertNotSame('ready', $back['api_state']);
$this->assertNotEmpty($back['api_issues']);
$this->actingAs(User::factory()->create());
$this->getJson('/investimentos/'.$this->caseId.'/preparacao')->assertForbidden();
$this->actingAs($this->owner);
DB::table('desk_cases')->where('id', $this->caseId)->update(['closed_at' => now()]);
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', [...$body, 'version' => $back['version'], 'operation_key' => (string) Str::uuid()])->assertConflict();
}
public function test_swot_reuses_only_published_competitiveness_and_flags_a_changed_public_source_without_replacing_conclusions(): void
{
$cluster = EconomicCluster::create(['code' => 'SWOT-'.bin2hex(random_bytes(6)), 'name' => 'SYNTHETIC cluster SWOT QA', 'sector' => 'Energia', 'status' => 'planned']);
$portfolio = InvestmentDossier::create(['cluster_id' => $cluster->id, 'title' => 'SYNTHETIC portfolio source', 'source' => 'SYNTHETIC QA only', 'content' => ['sector' => 'Energia'], 'publication_status' => 'published', 'approval_basis' => 'document_owner', 'approval_evidence' => 'SYNTHETIC QA fixture only', 'published_at' => now()]);
$service = app(ClusterCompetitiveness::class);
$draft = ['version' => 1, 'clusterId' => $cluster->id, 'sector' => 'Energia', 'region' => 'SYNTHETIC scope', 'forces' => []];
foreach (ClusterCompetitiveness::FORCES as $force) {
$draft['forces'][$force] = ['pressure' => 'medium', 'evidence' => 'SYNTHETIC documented test only', 'source' => 'SYNTHETIC source', 'period' => '2026', 'rationale' => 'SYNTHETIC rationale', 'limitations' => 'No economic inference', 'opportunity' => 'Hypothesis only'];
}
$service->change($cluster, $this->owner->id, 0, 'saved', ['draft' => $draft]);
$service->change($cluster, $this->owner->id, 1, 'approved', ['reason' => 'SYNTHETIC explicit QA source approval']);
$this->context([], [], ['portfolio' => 'portfolio:'.$portfolio->id]);
$view = $this->workspace();
$key = 'cluster:'.$cluster->id;
$this->assertContains($key, array_column($view['objects'], 'key'));
$rows = [];
foreach (['strength', 'weakness', 'opportunity', 'threat'] as $dimension) {
$rows[] = ['id' => (string) Str::uuid(), 'dimension' => $dimension, 'text' => 'SYNTHETIC deliberate '.$dimension, 'reference' => 'SYNTHETIC qualitative approved Competitiveness source', 'source_key' => $key, 'date' => now()->toDateString(), 'classification' => 'hypothesis'];
}
$view = $this->action($view, 'swot_save', ['draft' => ['object_key' => $key, 'scope' => 'SYNTHETIC same cluster and region', 'rows' => $rows, 'source_fingerprint' => $view['swot_fingerprint']]]);
$view = $this->action($view, 'swot_review', ['fingerprint' => $view['swot_fingerprint']]);
$approvedFingerprint = $view['swot_fingerprint'];
$draft['forces']['rivalry']['evidence'] = 'PRIVATE-UNAPPROVED-SWOT-SOURCE';
$service->change($cluster, $this->owner->id, 2, 'saved', ['draft' => $draft]);
$back = $this->workspace();
$this->assertFalse($back['swot_review_required']);
$this->assertStringNotContainsString('PRIVATE-UNAPPROVED-SWOT-SOURCE', json_encode($back['sources']));
$privateFingerprint = $back['swot_fingerprint'];
$service->change($cluster, $this->owner->id, 3, 'approved', ['reason' => 'SYNTHETIC explicit new public QA source']);
$back = $this->workspace();
$this->assertTrue($back['swot_review_required']);
$this->assertSame($rows, $back['swot']['rows']);
$changedFingerprint = $back['swot_fingerprint'];
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email], 'portal.profile_access_enabled' => false]);
$this->actingAs($manager);
$team = $this->workspace();
$this->assertTrue($team['can_review']);
$this->assertFalse($team['can_write']);
$team = $this->action($team, 'swot_review', ['fingerprint' => $team['swot_fingerprint']]);
$this->assertFalse($team['swot_review_required']);
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['action' => 'apiex_save', 'version' => $team['version'], 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC forbidden team overwrite', 'note' => 'must not save', 'additional_documents' => []])->assertForbidden();
$portfolio->update(['publication_status' => 'draft']);
$withdrawn = $this->workspace();
$this->assertTrue($withdrawn['swot_review_required']);
$this->assertSame($rows, $withdrawn['swot']['rows']);
$this->assertNotEmpty($withdrawn['swot']['sources_snapshot']);
file_put_contents(base_path('outputs/investment-preparation-20261110/source-changes.json'), json_encode(['synthetic' => true, 'approved_fingerprint' => $approvedFingerprint, 'private_edit_fingerprint' => $privateFingerprint, 'new_public_fingerprint' => $changedFingerprint, 'withdrawal_review_required' => $withdrawn['swot_review_required'], 'conclusions_preserved' => $rows === $withdrawn['swot']['rows'], 'team_reviewed_without_owner_write' => $team['can_review'] && ! $team['can_write']], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
}
public function test_opportunities_in_one_dossier_keep_independent_preparation_versions_and_cannot_reuse_another_export(): void
{
$entries = array_slice(app(CorridorAgribusinessOpportunities::class)->entries(), 0, 2);
$records = Opportunity::whereIn('management_creation_key', array_column($entries, 'key'))->orderBy('id')->get();
$this->assertCount(2, $records);
$sites = collect(app(InvestmentCorridorContext::class)->provinces('pemba-lichinga'))->pluck('id');
$before = $this->getJson('/investimentos/'.$this->caseId.'/contexto')->assertOk()->json();
$body = ['version' => $before['case']['version'], 'selection' => [], 'opportunity_choices' => [
['source' => 'opportunity:'.$records[0]->id, 'territory' => 'territory:'.$sites[0], 'activities' => ['production', 'processing']],
['source' => 'opportunity:'.$records[1]->id, 'territory' => 'territory:'.$sites[1], 'activities' => ['trade']],
]];
$plan = $this->postJson('/investimentos/'.$this->caseId.'/contexto/previa', $body)->assertOk()->json();
$this->postJson('/investimentos/'.$this->caseId.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$this->getJson('/investimentos/'.$this->caseId.'/preparacao')->assertUnprocessable();
$first = 'opportunity:'.$records[0]->id;
$second = 'opportunity:'.$records[1]->id;
$a = $this->getJson('/investimentos/'.$this->caseId.'/preparacao?opportunity='.urlencode($first))->assertOk()->json();
$this->assertSame($first, $a['opportunity']);
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['version' => 0, 'opportunity' => $first, 'action' => 'apiex_save', 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC scope1 only', 'note' => 'SYNTHETIC deliberate scope1', 'additional_documents' => []])->assertOk()->assertJsonPath('version', 1);
$b = $this->getJson('/investimentos/'.$this->caseId.'/preparacao?opportunity='.urlencode($second))->assertOk()->json();
$this->assertSame(0, $b['version']);
$this->assertSame('', $b['apiex']['note']);
$this->assertNotSame($a['context']['fields']['province_id'], $b['context']['fields']['province_id']);
$this->assertStringContainsString(urlencode($second), $b['links']['context']);
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['version' => 0, 'opportunity' => $second, 'action' => 'external', 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC cross-scope refused', 'kind' => 'delivery', 'date' => now()->toDateString(), 'reference' => 'SYNTHETIC nonexistent other export', 'export_id' => (string) Str::uuid(), 'proof_document' => $this->documents['identity']])->assertUnprocessable();
$context = $this->getJson('/investimentos/'.$this->caseId.'/contexto')->assertOk()->json();
$keep = ['version' => $context['case']['version'], 'selection' => [], 'opportunity_choices' => [$body['opportunity_choices'][1]]];
$plan = $this->postJson('/investimentos/'.$this->caseId.'/contexto/previa', $keep)->assertOk()->json();
$this->postJson('/investimentos/'.$this->caseId.'/contexto/confirmar', [...$keep, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$retired = $this->getJson('/investimentos/'.$this->caseId.'/preparacao?opportunity='.urlencode($first))->assertOk()->json();
$this->assertTrue($retired['historical']);
$this->assertFalse($retired['can_write']);
$this->assertSame('SYNTHETIC deliberate scope1', $retired['apiex']['note']);
$this->assertContains($first, array_column($retired['saved_scopes'], 'key'));
file_put_contents(base_path('outputs/investment-preparation-20261110/opportunity-scopes.json'), json_encode(['synthetic' => true, 'first_scope' => $first, 'second_scope' => $second, 'second_version_unchanged' => $b['version'], 'retired_scope_readable' => $retired['historical'], 'retired_scope_writable' => $retired['can_write'], 'histories' => array_column($retired['saved_scopes'], 'key')], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
}
public function test_history_paginates_past_twenty_five_without_replacing_working_data_or_external_records(): void
{
$service = app(InvestmentPreparationWorkspace::class);
for ($version = 0; $version < 27; $version++) {
$service->change($this->owner, $this->caseId, ['version' => $version, 'action' => 'apiex_save', 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC history boundary fixture through writer', 'note' => 'SYNTHETIC version'.($version + 1), 'additional_documents' => []]);
}
$first = $this->workspace();
$this->assertCount(25, $first['history']);
$this->assertSame(3, $first['history_next']);
$next = $this->getJson('/investimentos/'.$this->caseId.'/preparacao?history_before='.$first['history_next'])->assertOk()->json();
$this->assertSame([2, 1], array_column($next['history'], 'version'));
$this->assertNull($next['history_next']);
$this->assertSame(27, $next['version']);
$this->assertSame('SYNTHETIC version27', $next['apiex']['note']);
$last = $this->action($next, 'swot_restore', ['revision' => 1]);
$this->assertSame(28, $last['version']);
file_put_contents(base_path('outputs/investment-preparation-20261110/history-pages.json'), json_encode(['synthetic' => true, 'first_versions' => array_column($first['history'], 'version'), 'next_versions' => array_column($next['history'], 'version'), 'new_version_after_restore' => $last['version']], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
$this->assertSame('SYNTHETIC version27', $last['apiex']['note']);
}
}
@@ -0,0 +1,354 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { compileFunction } from 'node:vm';
import { createRequire } from 'node:module';
import React from 'react';
import ts from 'typescript';
import {
PreparationAttempt,
swotDimensions,
} from '../../resources/js/lib/investment-preparation.ts';
import { createOperationKey } from '../../resources/js/lib/operation-key.ts';
const require = createRequire(import.meta.url),
flush = () => new Promise((resolve) => setImmediate(resolve));
const fixture = () => ({
case: { id: 'qa-case', title: 'SYNTHETIC preparation', closed: false },
opportunity: null,
version: 0,
apiex: {
note: 'Saved APIEX note',
additional_documents: [],
review: null,
exports: [],
external: [],
},
swot: {
object_key: 'investment:qa-case',
scope: 'Saved SWOT scope',
rows: [],
source_fingerprint: 'a'.repeat(64),
review: null,
actions: [],
},
api_state: 'preparing',
api_issues: [],
api_fingerprint: 'b'.repeat(64),
swot_fingerprint: 'a'.repeat(64),
swot_review_required: true,
can_write: true,
can_review: true,
links: {
dossier: '/investimentos/qa-case',
context: '/investimentos/qa-case/contexto/editar',
documents: '/balcao-virtual/dossiers/qa-case',
editorial: '/acesso-gestao',
},
objects: [
{
key: 'investment:qa-case',
label: 'SYNTHETIC preparation',
kind: 'investment',
},
],
sources: [],
context: { revision: 1, fields: { project_title: 'SYNTHETIC' } },
assessment: { requirements: [] },
documents: [],
history: [],
});
const profileModule = { exports: {} };
compileFunction(
ts.transpileModule(
fs.readFileSync(
new URL('../../resources/js/lib/profile-json.ts', import.meta.url),
'utf8',
),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
['require', 'module', 'exports'],
)(require, profileModule, profileModule.exports);
const { ProfileValidationError } = profileModule.exports;
const source = fs.readFileSync(
new URL(
'../../resources/js/pages/investor-path/preparation.tsx',
import.meta.url,
),
'utf8',
),
compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX,
},
}).outputText;
const text = (n) =>
typeof n === 'string' || typeof n === 'number'
? String(n)
: React.isValidElement(n)
? React.Children.toArray(n.props.children).map(text).join('')
: '';
const walk = (node, parents = []) =>
React.isValidElement(node)
? [
{ node, parents },
...React.Children.toArray(node.props.children).flatMap((n) =>
walk(n, [...parents, node]),
),
]
: [];
function harness(initial = fixture()) {
let cursor = 0;
const slots = [],
calls = [],
mod = { exports: {} };
const hookReact = {
...React,
useEffect: () => {},
useState: (initial) => {
const i = cursor++;
if (!(i in slots))
slots[i] = typeof initial === 'function' ? initial() : initial;
return [
slots[i],
(value) => {
slots[i] =
typeof value === 'function' ? value(slots[i]) : value;
},
];
},
useRef: (initial) => {
const i = cursor++;
if (!(i in slots)) slots[i] = { current: initial };
return slots[i];
},
};
compileFunction(compiled, ['require', 'module', 'exports'])(
(name) =>
name === 'react'
? hookReact
: name === '@inertiajs/react'
? { Link: 'a' }
: name === '@/pages/investment/shared'
? {
Frame: 'div',
card: 'card',
input: 'input',
button: 'button',
}
: name === '@/lib/profile-json'
? {
ProfileValidationError,
profileJson: (url, body, method) =>
new Promise((resolve, reject) =>
calls.push({
url,
body: structuredClone(body),
method,
resolve,
reject,
}),
),
}
: name === '@/lib/operation-key'
? { createOperationKey }
: name === '@/lib/investment-preparation'
? { PreparationAttempt, swotDimensions }
: require(name),
mod,
mod.exports,
);
const h = {
calls,
render() {
cursor = 0;
const page = mod.exports.default({ workspace: initial });
return page.type(page.props);
},
findButton(label) {
return walk(this.render()).find(
({ node }) => node.type === 'button' && text(node) === label,
);
},
click(label) {
const entry = this.findButton(label);
assert.ok(entry, label);
if (
entry.node.props.disabled ||
entry.parents.some(
(p) => p.type === 'fieldset' && p.props.disabled,
)
)
return false;
entry.node.props.onClick();
return true;
},
field(label) {
const parent = walk(this.render()).find(
({ node }) =>
node.type === 'label' && text(node).startsWith(label),
);
assert.ok(parent, label);
const item = walk(parent.node).find(({ node }) =>
['input', 'textarea', 'select'].includes(node.type),
);
assert.ok(item);
return item.node;
},
change(label, value) {
this.field(label).props.onChange({ target: { value } });
},
async reply(value, index = calls.length - 1) {
calls[index].resolve(value);
await flush();
},
async fail(error, index = calls.length - 1) {
calls[index].reject(error);
await flush();
},
};
return h;
}
void test('uncertain preparation retries use an immutable operation and require a safe explicit recovery', () => {
const a = new PreparationAttempt(),
data = { draft: { scope: 'Deliberate' } };
const first = a.start(2, 'opportunity:7', 'swot_save', data, 'QA');
data.draft.scope = 'Later edit';
assert.equal(a.current().draft.scope, 'Deliberate');
assert.deepEqual(a.current(), first);
assert.throws(() => a.start(2, null, 'external', {}, 'other'));
assert.equal(a.canAdopt({ version: 2, history: [] }), false);
assert.equal(a.canAdopt({ version: 3, history: [] }), true);
assert.equal(
a.canAdopt({
version: 2,
history: [{ operation_key: first.operation_key }],
}),
true,
);
a.complete();
assert.equal(a.current(), null);
});
void test('actual APIEX form preserves local edits on lost response and retries the same body once', async () => {
const h = harness();
h.change('Nota para organizar a preparação', 'Deliberate local APIEX');
h.click('Guardar preparação documental');
assert.equal(h.calls.length, 1);
const first = structuredClone(h.calls[0].body);
assert.equal(h.click('Guardar preparação documental'), false);
await h.fail(new Error('Lost response'));
assert.equal(
h.field('Nota para organizar a preparação').props.value,
'Deliberate local APIEX',
);
h.click('Repetir a mesma tentativa');
assert.deepEqual(h.calls[1].body, first);
const next = fixture();
next.version = 1;
next.apiex.note = 'Deliberate local APIEX';
await h.reply(next);
assert.equal(
h.field('Nota para organizar a preparação').props.value,
'Deliberate local APIEX',
);
});
void test('saving APIEX does not clear unsaved SWOT changes or let them be reviewed as a saved server version', async () => {
const h = harness();
h.click('Análise SWOT');
h.change('Âmbito e fronteiras do objecto', 'Deliberate unsaved SWOT');
h.click('Documentos APIEX');
h.change('Nota para organizar a preparação', 'Another APIEX note');
h.click('Guardar preparação documental');
const next = fixture();
next.version = 1;
next.apiex.note = 'Another APIEX note';
await h.reply(next);
h.click('Análise SWOT');
assert.equal(
h.field('Âmbito e fronteiras do objecto').props.value,
'Deliberate unsaved SWOT',
);
assert.equal(
h.findButton('Conferi objecto,quatro dimensões e fontes desta versão')
.node.props.disabled,
true,
);
});
void test('consulting a concurrent version does not replace local conclusions; explicit keep-local adopts only the base', async () => {
const h = harness();
h.click('Análise SWOT');
h.change(
'Âmbito e fronteiras do objecto',
'Keep this deliberate local scope',
);
h.click('Consultar versão do servidor');
const next = fixture();
next.version = 4;
next.swot.scope = 'Other server scope';
await h.reply(next);
assert.equal(
h.field('Âmbito e fronteiras do objecto').props.value,
'Keep this deliberate local scope',
);
h.click(
'Manter os meus campos e usar esta versão como base para nova revisão',
);
assert.equal(
h.field('Âmbito e fronteiras do objecto').props.value,
'Keep this deliberate local scope',
);
h.click('Guardar análise privada');
assert.equal(h.calls[1].body.version, 4);
assert.equal(
h.calls[1].body.draft.scope,
'Keep this deliberate local scope',
);
});
void test('publication is never an automatic save/review effect; four dimensions remain explicit and uncertain by default', () => {
const h = harness();
h.click('Análise SWOT');
for (const label of [
'Acrescentar forças',
'Acrescentar fraquezas',
'Acrescentar oportunidades',
'Acrescentar ameaças',
])
assert.ok(h.findButton(label));
h.click('Acrescentar forças');
assert.equal(h.field('Classificação declarada').props.value, 'unconfirmed');
assert.equal(h.calls.length, 0);
const links = walk(h.render())
.filter(({ node }) => node.type === 'a')
.map(({ node }) => node.props.href);
assert.ok(links.includes('/acesso-gestao'));
});
void test('team review keeps existing owner-only inputs read-only while retaining the review action', () => {
const view = fixture();
view.can_write = false;
view.can_review = true;
const h = harness(view);
h.click('Análise SWOT');
assert.equal(
h.field('Âmbito e fronteiras do objecto').props.readOnly,
true,
);
assert.equal(
h.findButton('Guardar análise privada').node.props.disabled,
true,
);
assert.equal(
h.findButton('Conferi objecto,quatro dimensões e fontes desta versão')
.node.props.disabled,
false,
);
});
void test('a definite HTTP conflict permits comparison and correction without treating a rejected write as an uncertain commit',async()=>{const h=harness();h.change('Nota para organizar a preparação','Preserve after source conflict');h.click('Guardar preparação documental');const first=h.calls[0].body.operation_key;await h.fail(new ProfileValidationError('Fonte alterada',{},false,409));assert.equal(h.field('Nota para organizar a preparação').props.value,'Preserve after source conflict');h.click('Consultar versão do servidor');const next=fixture();next.api_fingerprint='c'.repeat(64);await h.reply(next);h.click('Manter os meus campos e usar esta versão como base para nova revisão');h.click('Guardar preparação documental');assert.notEqual(h.calls[2].body.operation_key,first);assert.equal(h.calls[2].body.note,'Preserve after source conflict');});
+18
View File
@@ -0,0 +1,18 @@
import fs from 'node:fs';
import path from 'node:path';
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
import JSZip from 'jszip';
const out=new URL('../outputs/investment-preparation-20261110/',import.meta.url);
const read=name=>JSON.parse(fs.readFileSync(new URL(name,out),'utf8'));
const digest=bytes=>createHash('sha256').update(bytes).digest('hex');
const api=read('apiex-recovered.json'),swot=read('swot-recovered.json'),sources=read('source-changes.json'),scopes=read('opportunity-scopes.json'),history=read('history-pages.json'),security=read('archive-security.json');
const bytes=fs.readFileSync(new URL('apiex-package.zip',out)),zip=await JSZip.loadAsync(bytes),manifest=JSON.parse(await zip.file('manifesto.json').async('string'));
assert.equal(manifest.case_id,api.case.id);assert.equal(api.api_state,'decision_received');assert.deepEqual(api.apiex.external.map(x=>x.kind),['delivery','clarification','response','decision']);assert.equal(api._qa_archive.initial_sha256,api._qa_archive.after_context_change_sha256);assert.equal(digest(bytes),api._qa_archive.initial_sha256);assert.equal(manifest.export.id,api.apiex.exports[0].id);assert.deepEqual(manifest.export.prepared.context,manifest.context);assert.notEqual(manifest.context.fields.project_summary,api.context.fields.project_summary);
for(const [n,doc] of manifest.export.documents.entries()){const name='anexos/'+String(n+1).padStart(2,'0')+'-'+doc.file_name.replace(/[^\p{L}\p{N}._-]+/gu,'-');assert.equal(digest(await zip.file(name).async('nodebuffer')),doc.sha256);}
assert.equal(new Set(swot.swot.rows.map(r=>r.dimension)).size,4);assert.ok(swot.swot.rows.every(r=>r.reference&&r.date&&['documented_fact','hypothesis','unconfirmed'].includes(r.classification)));assert.equal(swot.swot.actions[0].case_id,swot.case.id);assert.equal(swot._qa_native_task.case_id,swot.case.id);assert.equal(swot._qa_native_task.title,swot.swot.actions[0].title);assert.equal(swot._qa_native_task.origin_key,'swot:'+swot.swot.actions[0].id);assert.ok(swot.swot.sources_snapshot);assert.ok(swot.history.some(h=>h.action==='swot_review'));assert.equal(sources.approved_fingerprint,sources.private_edit_fingerprint);assert.notEqual(sources.approved_fingerprint,sources.new_public_fingerprint);assert.equal(sources.withdrawal_review_required,true);assert.equal(sources.conclusions_preserved,true);assert.equal(scopes.second_version_unchanged,0);assert.equal(scopes.retired_scope_readable,true);assert.equal(scopes.retired_scope_writable,false);assert.deepEqual(history.next_versions,[2,1]);assert.equal(security.revoked_scan_cached_download_status,404);assert.ok(security.encrypted_archive_count>0);
const preview=new URL('apiex-preview/',out);fs.mkdirSync(preview,{recursive:true});
for(const entry of Object.values(zip.files)){if(entry.dir)continue;const root=path.resolve(decodeURIComponent(preview.pathname).replace(/^\/([A-Za-z]:)/,'$1')),target=path.resolve(root,entry.name);assert.ok(target.startsWith(root+path.sep));fs.mkdirSync(path.dirname(target),{recursive:true});fs.writeFileSync(target,await entry.async('nodebuffer'));}
fs.writeFileSync(new URL('swot-recovered.pretty.json',out),JSON.stringify(swot,null,2));
const proof={synthetic:true,apiEx:{path:'existing dossier → requirements → documents → review → ZIP/index/attachments → external delivery/clarification/response/decision',archive_sha256:digest(bytes),attachments_verified:manifest.export.documents.length,preserved_after_context_change:true,no_automatic_submission_or_approval:true},swot:{path:'existing context/evidence → four dimensions → review → linked native task in same dossier → recovery',private_by_default:true,classification_source_date_preserved:true,source_versions_preserved:true,changed_public_sources_require_review:true,private_source_edits_not_exposed:true,deliberate_edits_preserved:true},scope_isolation:true,historical_scopes_readable_without_reactivation:true,history_beyond25:true,malware_guard_on_archived_download:true,qa_database:'simulator_fiscal_delta_20261021_test',qa_port:55441,database_transactions:'rolled back by native test trait; QA sequences may advance',installed:false,browser_visual_review:'pending; connector unavailable',no_new_database_or_port_or_migration_or_real_account:true};
fs.writeFileSync(new URL('verification.json',out),JSON.stringify(proof,null,2));console.log(JSON.stringify(proof));
+51
View File
@@ -0,0 +1,51 @@
<?php
$root = dirname(__DIR__);
if (! in_array($argv[1] ?? '', ['test', 'analyse'], true) || basename($root) !== 'portal-investidor') {
throw new RuntimeException('Explicit local test operation required.');
}
$proofDirectory = $root.'/outputs/investment-preparation-20261110';
if (! is_dir($proofDirectory)) {
mkdir($proofDirectory, 0700, true);
}
$analysisConfiguration = "includes:\n - ../../phpstan.neon\nparameters:\n tmpDir: phpstan-cache\n";
if (! is_file($proofDirectory.'/phpstan.neon')) {
file_put_contents($proofDirectory.'/phpstan.neon', $analysisConfiguration);
}
foreach (['APP_ENV' => 'testing', 'APP_KEY' => 'base64:'.base64_encode(str_repeat('Q', 32)),
'APP_CONFIG_CACHE' => $root.'/outputs/investment-preparation-20261110/no-config.php',
'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441',
'DB_DATABASE' => 'simulator_fiscal_delta_20261021_test', 'DB_USERNAME' => 'investmoz_test', 'DB_PASSWORD' => '',
'DB_URL' => '', 'DATABASE_URL' => '', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array',
'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array', 'SUITECRM_ENABLED' => 'false'] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
$pdo = new PDO('pgsql:host=127.0.0.1;port=55441;dbname=simulator_fiscal_delta_20261021_test', 'investmoz_test', '');
$identity = $pdo->query('select current_database() as name,inet_server_port() as port')->fetch(PDO::FETCH_ASSOC);
if ($identity['name'] !== 'simulator_fiscal_delta_20261021_test' || (int) $identity['port'] !== 55441) {
throw new RuntimeException('Isolated QA identity required.');
}
if ($argv[1] === 'analyse') {
$argv = ['phpstan', 'analyse', '--no-progress', '--memory-limit=512M', '--configuration='.$root.'/outputs/investment-preparation-20261110/phpstan.neon', 'app/Services/InvestmentPreparationWorkspace.php', 'app/Http/Controllers/InvestmentPreparationController.php', 'app/Services/InvestorPath.php', 'routes/investor-path.php'];
$_SERVER['argv'] = $argv;
$_SERVER['argc'] = count($argv);
require $root.'/vendor/autoload.php';
require $root.'/vendor/phpstan/phpstan/phpstan';
exit;
}
$case = 'InvestmentPreparationWorkspaceTest';
$filter = $argv[2] ?? null;
if ($filter !== null && (strlen($filter) > 128 || ! preg_match('/^[a-zA-Z0-9_|]+$/D', $filter))) {
throw new RuntimeException('Explicit QA test filter required.');
}
$argv = ['phpunit', '-c', 'phpunit.investmoz.xml', '--do-not-cache-result', 'tests/Feature/'.$case.'.php'];
if ($filter !== null) {
$argv[] = '--filter';
$argv[] = $filter;
}
$argc = count($argv);
$_SERVER['argv'] = $argv;
$_SERVER['argc'] = $argc;
require $root.'/vendor/autoload.php';
require $root.'/vendor/phpunit/phpunit/phpunit';