Files
Investidor-V2/app/Services/InvestmentPreparationWorkspace.php
T

494 lines
35 KiB
PHP

<?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 = [];
foreach ($context['sector_references'] ?? [] as $reference) {
$current = hash_equals((SectorResourceReferences::SOURCES[$reference['sector_slug']] ?? ''), $reference['source_sha256'] ?? '') ? $reference : ['unavailable' => true, 'source_sha256' => (SectorResourceReferences::SOURCES[$reference['sector_slug']] ?? '')];
$evidence['sector-resource:'.$reference['key']] = ['key' => 'sector-resource:'.$reference['key'], 'label' => $reference['title'], 'consulted' => $reference, 'current' => $current, 'notice' => 'Referência sectorial e de cadeia de valor; não certifica direitos, licenças nem disponibilidade de projecto.'];
}
$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);
}
}
});
}
}