Complete APIEX documentary preparation and private contextual SWOT in existing dossiers
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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');});
|
||||
@@ -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));
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user