Group investor reports and preserved financial copies in their existing dossier
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-11-13 14:09:52 +02:00
parent bb6824d75d
commit 07182c7c73
12 changed files with 1211 additions and 1 deletions
@@ -20,6 +20,16 @@ use Inertia\Response;
class InvestorPathController extends Controller
{
public function fiscalReport(Request $request, string $id, string $reportId, \App\Services\SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(app(InvestorPath::class)->available(), 404);
$request->validate(['opportunity' => 'nullable|string|max:150']);
$data = $drafts->dossierReport($request->user(), $id, $reportId, $request->query('opportunity'));
return response()->json($data)->header('Content-Disposition', 'attachment; filename="relatorio-fiscal-'.$reportId.'.json"')
->header('X-Content-Type-Options', 'nosniff');
}
public function index(Request $request, InvestorPath $path): JsonResponse|Response
{
abort_unless($path->available(), 404);
@@ -280,6 +280,24 @@ final class InvestmentPreparationWorkspace
return collect($this->rows($this->current($id, $scope)['swot']['actions']))->firstWhere('id', $actionId);
}
/** Reuses the authorised workspace and its preserved exports; never creates an export or a review.
* @return array<string,mixed> */
public function reportSummary(User $actor, string $id, ?string $opportunity): array
{
$view = $this->view($actor, $id, $opportunity);
$query = $view['opportunity'] ? '?opportunity='.urlencode($view['opportunity']) : '';
$base = '/investimentos/'.$id.'/preparacao';
return ['case_id' => $id, 'opportunity' => $view['opportunity'], 'version' => $view['version'],
'context_revision' => $view['context']['revision'], 'href' => $base.$query, 'api_state' => $view['api_state'],
'api_issues' => $view['api_issues'], 'external' => $view['apiex']['external'],
'exports' => array_map(fn ($export) => ['id' => $export['id'], 'at' => $export['at'],
'context_revision' => $export['context_revision'], 'fingerprint' => $export['fingerprint'],
'historical' => $export['fingerprint'] !== $view['api_fingerprint'],
'href' => $base.'/exportar/'.$export['id'].$query], $this->rows($view['apiex']['exports'])),
'swot' => $view['swot'], 'swot_review_required' => $view['swot_review_required']];
}
/** @return list<string> */
public function taskKeys(User $actor, string $id, string $opportunity): array
{
+12
View File
@@ -8,6 +8,7 @@ use App\Models\SimulationFormDraft;
use App\Models\User;
use App\Support\StructuredData;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -171,6 +172,17 @@ final class InvestorPath
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null,
'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null,
'results' => $results, 'requests' => $requests, 'documents' => $documents,
'fiscal_reports' => (int) $case->user_id === $actor->id && (count($allChoices) < 2 || $opportunity !== null)
? app(SimulationFormDrafts::class)->dossierReportSources($actor, $id, $opportunity) : [],
'land_report' => $landCase ? ['id' => $landCase->id, 'version' => $landCase->version, 'updated_at' => $landCase->updated_at?->toIso8601String(),
'href' => '/investidor/terra-duat/'.$landCase->id.'/imprimir'] : null,
'preparation_report' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() && (count($allChoices) < 2 || $opportunity !== null)
? app(InvestmentPreparationWorkspace::class)->reportSummary($actor, $id, $opportunity) : null,
'company_reports' => ($owner || $manager) ? array_filter([
'comparison' => isset($payload['company_comparison']) ? Arr::only($payload['company_comparison'], ['revision', 'at']) : null,
'preparation' => isset($payload['company_preparation']) ? Arr::only($payload['company_preparation'], ['revision', 'at']) : null,
'guides' => array_map(fn ($guide) => ['revision' => $guide['revision'], 'at' => $guide['at'], 'slug' => $guide['guide']['slug'], 'title' => $guide['guide']['title'] ?? $guide['guide']['slug']], $payload['investment_guides'] ?? []),
]) : [],
'zone_contexts' => ($owner || $manager) ? ($path['zone_contexts'] ?? []) : [],
'sector_contexts' => ($owner || $manager) ? ($path['sector_contexts'] ?? []) : [],
'sector_references' => ($owner || $manager) ? ($path['sector_references'] ?? []) : [],
+58
View File
@@ -146,6 +146,9 @@ final class SimulationFormDrafts
}
$this->requireMatch($request, $draft);
$associatedContext = json_decode($economic['context_applicability_snapshot'] ?? '', true);
$sourceSnapshot['association_opportunity'] = $economic['context_opportunity'] ?? $associatedContext['consumer_opportunity'] ?? null;
$sourceSnapshot['association_context_revision'] = $associatedContext['context_revision'] ?? null;
$sources[] = $sourceSnapshot;
$economic['simulation_sources'] = $sources;
$economic['reuse_simulation'] = 'yes';
@@ -223,6 +226,61 @@ final class SimulationFormDrafts
});
}
/** Metadata only, from the draft owned by this dossier's titular; no broader dossier ACL grants financial access.
* @return list<array<string,mixed>> */
public function dossierReportSources(User $actor, string $caseId, ?string $opportunity): array
{
$case = app(VirtualDesk::class)->case($actor, $caseId);
if ((int) $case->user_id !== $actor->id || ! $case->simulation_id) { return []; }
$opportunity = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $opportunity, true);
return DB::transaction(function () use ($actor, $case, $opportunity): array {
$this->setContext($actor->id, null);
$draft = $this->ownedQuery($actor->id, null)->whereKey($case->simulation_id)->first();
if (! $draft) { return []; }
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
$sources = [];
$records = $economic['simulation_sources'] ?? [];
abort_unless(is_array($records), 409, 'Referências de relatórios incompatíveis; original preservado.');
foreach ($records as $source) {
if (! is_array($source) || ! Str::isUuid($source['simulation_id'] ?? '') || ! Str::isUuid($source['report_id'] ?? '')) { continue; }
if (isset($source['association_opportunity']) && $source['association_opportunity'] !== $opportunity) { continue; }
$sources[] = [...array_intersect_key($source, array_flip(['simulation_id', 'report_id', 'report_version_no', 'report_sha256', 'source_updated_at', 'consented_at'])),
'opportunity' => $source['association_opportunity'] ?? null, 'scope_state' => ! array_key_exists('association_opportunity', $source) ? 'unconfirmed' : ($source['association_opportunity'] === null ? 'common' : 'identified'),
'href' => '/investimentos/'.$case->id.'/relatorios/fiscais/'.$source['report_id'].($opportunity ? '?opportunity='.urlencode($opportunity) : '')];
}
return $sources;
});
}
/** Reads the already preserved copy, including a source originally calculated anonymously.
* @return array<string,mixed> */
public function dossierReport(User $actor, string $caseId, string $reportId, ?string $opportunity): array
{
$case = app(VirtualDesk::class)->case($actor, $caseId);
abort_unless((int) $case->user_id === $actor->id && $case->simulation_id, 403);
$opportunity = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $opportunity, true);
return DB::transaction(function () use ($actor, $case, $reportId, $opportunity): array {
$this->setContext($actor->id, null);
$draft = $this->ownedQuery($actor->id, null)->whereKey($case->simulation_id)->firstOrFail();
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
$records = $economic['simulation_sources'] ?? [];
abort_unless(is_array($records), 409, 'Referências de relatórios incompatíveis; original preservado.');
$source = null;
foreach ($records as $item) {
if (is_array($item) && ($item['report_id'] ?? null) === $reportId) { $source = $item; break; }
}
abort_unless(is_array($source) && is_array($source['report_snapshot'] ?? null), 404, 'Cópia preservada indisponível; original não substituído.');
abort_if(isset($source['association_opportunity']) && $source['association_opportunity'] !== $opportunity, 404);
return ['case_id' => $case->id, 'opportunity' => $source['association_opportunity'] ?? null,
'scope_state' => ! array_key_exists('association_opportunity', $source) ? 'unconfirmed' : ($source['association_opportunity'] === null ? 'common' : 'identified'), 'draft_id' => $draft->id, 'draft_version' => $draft->version_no,
...array_intersect_key($source, array_flip(['simulation_id', 'report_id', 'report_version_no', 'report_sha256', 'report_snapshot', 'consented_at']))];
});
}
/** @return array{id:string,title:string,updated_at:string|null,expires_at:string|null,version_no:int}|null */
public function claimPreview(Request $request): ?array
{