Group investor reports and preserved financial copies in their existing dossier
tests / ci (push) Waiting to run
tests / ci (push) Waiting to run
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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'] ?? []) : [],
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -9748,3 +9748,17 @@ S08 permanece por concluir, separada da revis?o visual; a especifica??o da janel
|
||||
### 10/11/2026 ? S08: melhoria do percurso existente de licenciamento pr?vio
|
||||
|
||||
?mbito esclarecido pela Tempo; substitui o anterior saldo ?por identificar?. Implementa??o e sete provas frontend dirigidas em `67344418`: estado sem repeti??o, registo/ac??o principal vis?veis, redu??o dos blocos expans?veis e abertura/foco da etapa por `#S08`, preservando campos/contexto/vers?o/grava??o e guardas existentes. TypeScript, lint dirigido e build QA aprovados. Sem altera??o de backend/hist?rico/dados ou repeti??o das provas aceites n?o afectadas. Detalhes e limites em `docs/investmoz/v2/auditoria-portal/ajustes-por-ambito-2026-11-10.md`. Revis?o visual e instala??o mant?m-se pendentes e separadas. Pacote contador/aliases preservado; sem push e cluster 55441 mantido ligado.
|
||||
|
||||
|
||||
## 13/11/2026 — relatórios agrupados no dossier: implementação em QA
|
||||
|
||||
Pedido do proprietário: verificar e resolver a dispersão das saídas produzidas pelas etapas do investimento. No percurso existente `/investimentos/{id}#relatorios`, o índice reúne resultados financeiros, versões fiscais preservadas, comparação/preparação da empresa, guias guardados, análise SWOT, exportações APIEX, preparação de acesso à terra e documentos das etapas. Cada referência conserva dossier, oportunidade ou contexto comum/por confirmar, revisão, data quando registada e estado; etapas sem resultado associado são explicitadas. A mesma versão documental ligada a mais de um pedido aparece uma vez, sem fundir originais diferentes.
|
||||
|
||||
Exportação consolidada Word e HTML para impressão/PDF reutiliza os geradores existentes e os cálculos de viabilidade já aceites. Inclui contexto do investimento, etapas e pendências, financiamento/orçamento/empréstimo/ponto de equilíbrio recuperados das entradas guardadas, conclusões SWOT com fonte/data/classificação, preparação APIEX e referências aos documentos. Exportações APIEX históricas mantêm o original, sem acrescentar SWOT privada ao pacote destinado à entidade externa. O índice dá acesso aos originais pelas autorizações existentes; o relatório não envia mensagens, submete processos, concede crédito, executa pagamentos ou publica conteúdo.
|
||||
|
||||
A cópia fiscal preservada no rascunho é recuperável pelo titular em `/investimentos/{id}/relatorios/fiscais/{reportId}`, incluindo quando a simulação original era anónima ou deixou de existir. A posse de acesso administrativo ao dossier não concede acesso financeiro. Fontes fiscais novas conservam a oportunidade/revisão no momento da associação; versões antigas sem essa identificação permanecem disponíveis como âmbito por confirmar, sem serem atribuídas silenciosamente à oportunidade actual. Preparação de terra não é apresentada como título DUAT. Seleccionar oportunidade é necessário antes de consolidar um dossier com várias escolhas.
|
||||
|
||||
Provas: `outputs/dossier-reports-20261113/`. Vinte e um testes frontend aprovados; prova final do servidor com 44 asserções, em dados sintéticos e transacção revertida, além da regressão focada anterior de quatro testes/151 asserções. Sem migrações ou novas permissões. Estado: implementado em QA; instalação desta alteração e conferência visual no uso normal ainda pendentes. O saldo dos restantes módulos e resíduos fiscais permanece inalterado.
|
||||
|
||||
|
||||
Relatórios do dossier — conferência final de 13/11/2026: regressão focada do servidor 4 testes/168 asserções; caso final de recuperação, descargas privadas, âmbito, repetição e bloqueio com módulo desactivado 45 asserções. Os 21 testes frontend passaram; tipos, análise PHP, lint e compilação QA aprovados. Delta próprio de relatórios preparado, sem instalação remota e sem interferir na incorporação demográfica em curso.
|
||||
|
||||
@@ -11126,3 +11126,4 @@ AUD-SIM-L1-P131,"Facto gerador/exigibilidade: entrega, serviço, importação, i
|
||||
AUD-SIM-L1-P132,"Exigibilidade em uso próprio,comissão,consignação e adiantamentos",Refinamento Tempo24/10 do saldoL1; origemAUD-SIM-INT02; CIVA2016 PDF8–9 e alterações aplicáveis,Simulador/IVA — cobertura por operacao,/simulador,"G11B/P132 instalado e8b5ae43:own_goods,own_services,commission_delivery,consignment_deadline,advance_payment. Eventos7(4)/(5)/(6) e8c:afectações/transmissões assimiladas3(3)e/f,serviços4(2),disponibilização pelo comissário,termo180dias de mercadorias à consignação3(3)d e pagamentos anteriores à factura. Corrigida remissão a consignação,não embalagensP102.8a/b/c separam parcela recebida e restante,sem dupla contagem.",Disponível na homologação e8b5ae43;delta instalado conferido,19frontend/API1/65rollback/tipos/build/EdgeCUA;provas anteriores reutilizadas.,"Demonstrado em QA e entregue na homologação e8b5ae43,no âmbito do ramo","Sem pendência neste recorte de eventos7/exigibilidade8 parametrizada;2026contexto;facto gerador7 e exigibilidade8 por data/âmbito;uma parcela de recebimento e restante por linha,ou parcelas de âmbitos distintos,sem agregação de totais entre linhas. Prazo de emissão é parâmetro legal identificado,não calendário presumido;importação segue7c. P104parcial não aplica câmbio fiscal.Sem nova base/IVA,taxa,dedução,campo11,regularização ou totaisModeloA;P133regimes suspensivos/viagens mantém âmbito separado.","Eventos7(4)/(5)/(6) e8c:afectações/transmissões assimiladas3(3)e/f,serviços4(2),disponibilização pelo comissário,termo180dias de mercadorias à consignação3(3)d e pagamentos anteriores à factura. Corrigida remissão a consignação,não embalagensP102.8a/b/c separam parcela recebida e restante,sem dupla contagem.",Condicoes/fonte/periodo desta operacao -> calculo ou nao aplicacao explicada -> gravacao -> recuperacao -> relatorio coerentes; desconhecido nao e zero. Preservar oportunidade e historico.,Saldo L1; sequencia do incremento indicada,,AUD-SIM-INT02,1,,,,,,,,,,"P131/P132 entregue conjuntamente e8b5ae43 sobre92eaa935;backup/restauro174tabelas/369ficheiros e delta conferidos. | Histórico: QAconcluído7e6e462e. | Histórico: PreparaçãoQA P104/P131/P132;118fechos inventariados antes dos testes,sem perdas confirmadas. | Histórico: 25/10/2026 impedimento: Falta exigibilidade dos eventos específicos de uso próprio/comissão/embalagens/adiantamentos. | Operacao discriminada do saldo anterior; e desenvolvimento nao demonstrado, nao regressao ou perda de fonte aceite.",Manter encerrado no âmbito entregue;reutilizar provas e conferir apenas deltas afectados.,currencyEvents nosnapshot/API/dossier;convertCents existente exportado semmudarlógica,Backup/restauro/destino autenticado;sem schema/permissões alterados ou ensaios sintéticos no activo.,"CIVA Lei13/2016:7(1)–(7),PDF8/p1426(266);8,PDF9/p1426(267);15(8),PDF14/p1426(272),páginas visualmente conferidas.Lei22/2022 eLei10/2025 não alteram7/8;15(8)mantido.RIVADecreto7/2008,21,PDF4:regra histórica5dias úteis e excepções;prazo actual não automatizado,exige disposição/calendário/âmbito identificados.","Desenvolvimento,QA e entrega conjunta concluídos no recorte;L1aberto",Sem esforço funcional pendente neste recorte;não estimativa de todoL1.,"Cotejo documental e revisao do codigo; candidato544068e8 preservado. Sem ensaio funcional deste ramo. | C:\laragon\www\portal-investidor\outputs\iva-l1-currency-events-20261025\preservation-before-tests.json | docs\investmoz\simulations\iva-l1-currency-events-qa-20261025.json | iva_l1_currency_events_P131_P132_partial_P104_homologation_20261025:manifesto/24marcadores/5hashes/simuladorHTTP200,preservação174tabelas/369ficheiros/29fichas/28relações/408geometrias;QA individual reutilizada",G11B,25/10/2026 — entrega conjuntaP131/P132 e8b5ae43,"{""article"": ""7(4–7)"", ""source"": ""CIVA2016 PDF8–9 e alterações aplicáveis"", ""scope"": ""2026 somente quando condicoes e periodo aplicavel forem determinados"", ""next_increment"": ""L1-R1P: período fiscal efectivo, distinto de mês orçamental""}",,,,,,,,,Nao confundir prova dos dez tratamentos anteriores com cobertura deste ramo.,,,,Entregue na homologação e8b5ae43;L1aberto
|
||||
AUD-SIM-L1-P133,Saída de regimes suspensivos e operações de viagens,Refinamento Tempo24/10 do saldoL1; origemAUD-SIM-INT02; CIVA2016 PDF8–9 e alterações aplicáveis,Simulador/IVA — cobertura por operacao,/simulador,"P133 instalado a48e9749: Cessação efectiva de regime7(8), distinta de mudança de armazém/transferência; viagens7(9)/RIVA40 pelo primeiro entre integral e início/primeiro serviço, sem subtrair dia nem inferir ordem intradiária. Exigibilidade8 por factura/recebimento separada, incluindo sinal e saldo integral sem dupla soma. Sem montante de imposto, desembaraço, direito ou ICE apurados.",Disponível na homologação a48e9749; delta instalado conferido no âmbito demonstrado.,29testes frontend; API privada1teste/68asserções; tipos/build; Edge gravação/recuperação/relatório;10operações/5períodos/12eventos.,"Demonstrado em QA e entregue na homologação a48e9749, no âmbito do ramo","Sem pendência de entrega neste recorte de margem/eventos. Sem cálculo de IVA devido ou dedução. Continuidade do apuramento/liquidação em AUD-SIM-INT02; deduções/regularizações nos lotes próprios, incluindoL2/L3, não encerradas. Factura/modelo oficial/regularização não executados.","Fontes do regime fiscal e evento específico cotejadas; moedaMZN para base, âmbito completo por operador/período, dados/datas/referências concretos. P104 não bloqueia classificação/eventos independentes.",Condicoes/fonte/periodo desta operacao -> calculo ou nao aplicacao explicada -> gravacao -> recuperacao -> relatorio coerentes; desconhecido nao e zero. Preservar oportunidade e historico.,Saldo L1; sequencia do incremento indicada,,AUD-SIM-INT02,1,,,,,,,,,,"P129/P133 entregues conjuntamente a48e9749 sobre df43aeb1;123fechos preservados; backup/restauro e delta conferidos; limitação ligada a AUD-SIM-INT02. | Histórico: P133 QA concluído no âmbito demonstrado,123fechos preservados,sem repetição de provas aceites. | Histórico: Preparação QA viagens/eventos:123fechos inventariados antes dos testes; nenhuma perda confirmada. | Histórico: 25/10/2026 impedimento: Falta motor de saída de regimes suspensivos e evento das viagens,no regime específico. | Operacao discriminada do saldo anterior; e desenvolvimento nao demonstrado, nao regressao ou perda de fonte aceite.","Manter encerrado no âmbito de margem/eventos demonstrado, reutilizando provas. Conservar ligação ao trabalho fiscal restante em AUD-SIM-INT02: apuramento/liquidação, deduções e regularizações próprias não concluídos por esta entrega.","Snapshot/API/dossier, moeda, datas UTC, histórico e motor de exigibilidade8 existentes; viagem classificada associada à mesma oportunidade, sem copiar edições deliberadas.","Destino autenticado e backup/restauro; sem migração, alteração de permissões, importação/publicação documental ou ensaio sintético no activo.","RIVA Decreto7/2008,38–46,acervoBR16-I16/04/2008PDF6–8/p104–106 (6/7 conferidas visualmente,46(2) texto extraídoPDF8). CIVA Lei13/2016,4(5)/5(2),PDF7/p1426(265);7(8)/(9)/8,PDF9/p1426(267);14(1),PDF13/p1426(271),páginas visualmente conferidas. CIVA17(1),Lei22/2022PDF3,16% usado no divisor da base;Lei10/2025não altera4/7/8/17. Não confundir regime fiscal com licenciamento turístico.","Desenvolvimento, QA e entrega conjunta concluídos no âmbito de margem/eventos; IVA devido/dedução não calculados e trabalho restante rastreável.",Sem esforço de entrega pendente neste recorte; não representa fecho ou estimativa do restante trabalho fiscal.,Cotejo documental e revisao do codigo; candidato544068e8 preservado. Sem ensaio funcional deste ramo. | C:\laragon\www\portal-investidor\outputs\iva-l1-travel-events-20261025\preservation-before-tests.json | docs\investmoz\simulations\iva-l1-travel-events-qa-20261025.json | iva_l1_travel_events_P129_P133_homologation_20261025:24marcadores/6hashes/simuladorHTTP200;174tabelas/369ficheiros/29fichas/28relações/408geometrias;provasQA reutilizadas;fiscal_continuity:AUD-SIM-INT02,Viagens e eventos P129/P133,25/10/2026 — entrega conjunta a48e9749,"{""article"": ""7(8–9)"", ""source"": ""CIVA2016 PDF8–9 e alterações aplicáveis"", ""scope"": ""2026 somente quando condicoes e periodo aplicavel forem determinados"", ""next_increment"": ""L1-R1P: período fiscal efectivo, distinto de mês orçamental""}",,,,,,,,,Nao confundir prova dos dez tratamentos anteriores com cobertura deste ramo.,,,,Entregue na homologação a48e9749 no âmbito demonstrado;L1 aberto
|
||||
INE-DEMO-01,"Integração nacional de dados demográficos INE na viabilidade existente, importador em duas etapas, séries/versionamento, comparação territorial e adenda nacional.",Pedido colado pelo proprietário nesta sessão; execução expressamente confirmada; Macate/Vanduzi autorizados apenas em QA em 13/11/2026.,Território; viabilidade; percurso; SWOT; simulador,/localizacoes/demografia; /api/v1/investment/demography; /management/demography,DemographicImport; DemographicEvidence; leitor XLSX; cadastro/mapa/comparação/simulador e fontes SWOT existentes reutilizados.,"Homologação:4988ca89 instalado,erro500 de processos corrigido;preparaçõesCLI12/920456 privadas,por confirmaçãoMFA. Local:12lotes/920456 instalados e verificados.","154706 observações reais INE; três originais com scan real/hash; repetição sem duplicação; 72 reconciliações entre exemplares; comparação GONDOLA/GURO. Homologação: backup/restauro190 tabelas/369 ficheiros;11 tabelas de negócio preservadas;168 correspondências+15 distritos autorizados,sem pendências;HTTPS200 e revisão b20068d0 confirmada. Correcção df543a44:3testes/17asserções;HTTPS confirma revisão;ambos os titulares designados passam a autorização de leitura;nenhuma permissão alterada;hashes de código/configuração conferidos. Correcção500:2testes/20asserções sem proc_open;14testes/52asserções consumidores;8preparador;PHPStan aprovado. PHP-FPM real confirmou processos bloqueados,cacheCLI e antimalware disponíveis,hashes correctos,sem actor/sessão ou escrita de negócio;HTTPS confirma4988ca89.",Código instalado e verificado no PHP-FPM real;incorporação remota dos dados pendente da confirmação autenticada.,"Confirmar coortes initial/remaining/zambezia na sessão com MFA e conferir recibos,12 lotes/920456 observações e consulta territorial. Conferência visual administrativa pendente.",PostgreSQL/PostGIS QA; ClamAV; Python stdlib; autenticação e MFA existentes; nenhum novo aprovador.,"Três ficheiros associados aos territórios, valores exactos, séries 2017–2050, consulta sectorial/temporal, duas localizações, fontes/versões, inconsistências e acessos demonstrados em QA. Homologação manual restante registada no relatório.",Alta,,,,,"Consulta pública desktop/mobile conferida; visual administrativa pendente, testes HTTP aprovados.",11 testes de integração aprovados.,Três lotes QA; 154706 observações; idempotência e unicidade verificadas.,Consulta por território/ano/exemplar; desactivação/reaprovação preservam histórico.,"População/idade/residência, vitais, fontes e limitações; sem extrapolação, procura automática ou probabilidade de sucesso.",,,,,"Sem pendência de importação dos ficheiros provinciais fornecidos. Conferência visual administrativa,ressalvas/unidades ausentes e indicadores sem valores mantidos;outros ambientes não instalados por esta operação.","PortalProfileInstallation/MFA existentes; DemographicInstallation; DemographicImport; InvestmentGeography; simulador, mapa/comparação e fontes SWOT existentes.",,Proprietário executou a disponibilização autorizada de Zambezia.xlsx e cinco distritos;recibo autenticado e resultado confirmados sem repetição.,,,"outputs/demography-20261113/zambezia/installation-status.json e verification.json:recibo autenticado09:33:38UTC,hash/lote/count126684,total12lotes/920456,preservação11lotes e oito tabelas de negócio,série provincial e cinco distritos públicos. operational-status.json actualizado;ensaios e backup/restauro anteriores preservados.",demografia-ine,13/11/2026 — instalação autenticada concluída; conferência técnica de leitura sem repetição da operação,,,,,,,,,,,,,,
|
||||
REL-DOSSIER-20261113,"Agrupar no dossier correspondente os relatórios produzidos pelas etapas, com versão, oportunidade e recuperação.",Pedido expresso do proprietário em 13/11/2026: «tem de ser resolvido».,Percurso do investidor; dossier; relatórios,/investimentos/{id}#relatorios; /investimentos/{id}/relatorios/fiscais/{reportId},"Índice e relatório consolidado no percurso existente; reutiliza os geradores Word/HTML, cálculos e cópias preservadas.",QA implementado; esta alteração ainda não instalada na homologação.,21 testes frontend; recuperação e isolamento com 45 asserções em QA; originais e histórico preservados.,Implementado em QA; instalação pendente,Instalar o delta autorizado e conferir visualmente o uso normal. Não encerra funcionalidades ainda inexistentes noutros módulos.,,"Mesmo dossier/oportunidade → índice por etapa → relatório Word/HTML → acesso a versões APIEX, fiscais e documentos → recuperação sem misturar oportunidades ou expor finanças a gestores.",Alta,,,,,Índice responsivo e exportação consolidada,"Descarga privada da cópia fiscal preservada, autorizada por titular e dossier","Reutiliza os registos existentes; associação fiscal nova conserva âmbito, sem reescrever fontes anteriores.",Aprovada em prova sintética; sem depender de acesso à simulação anónima original.,Versão/data/estado/origem; pendências e âmbito por confirmar explícitos.,,,,,,,,"Acrescentar índice e consolidar saídas existentes; sem módulo paralelo, publicação ou envio automático.",Instalação e conferência; implementação QA concluída,,outputs/dossier-reports-20261113/,,,,,,,,,,,,,,,,
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -0,0 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
createInvestorDossierReport,
|
||||
investorDossierReportIndex,
|
||||
type InvestorDossierReportInput,
|
||||
} from '@/lib/investor-dossier-report';
|
||||
import {
|
||||
createProjectDossierPrintHtml,
|
||||
generateProjectDossierDocx,
|
||||
} from '@/lib/project-dossier-report';
|
||||
|
||||
export function InvestorDossierReports({
|
||||
dossier,
|
||||
}: {
|
||||
dossier: InvestorDossierReportInput;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const requiresChoice =
|
||||
dossier.opportunity_options.length > 1 && !dossier.context_opportunity;
|
||||
const entries = requiresChoice ? [] : investorDossierReportIndex(dossier);
|
||||
async function exportReport(format: 'word' | 'html') {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const model = createInvestorDossierReport(dossier);
|
||||
const blob =
|
||||
format === 'word'
|
||||
? await generateProjectDossierDocx(model, [], true)
|
||||
: new Blob(
|
||||
[
|
||||
await createProjectDossierPrintHtml(
|
||||
model,
|
||||
[],
|
||||
true,
|
||||
),
|
||||
],
|
||||
{ type: 'text/html;charset=utf-8' },
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `dossier-${dossier.case.id}-v${dossier.case.version}-${dossier.context_opportunity ? encodeURIComponent(dossier.context_opportunity) : 'comum'}.${format === 'word' ? 'docx' : 'html'}`;
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: 'Não foi possível preparar o relatório. Os registos foram preservados.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section id="relatorios" className="space-y-4 rounded-xl border p-4">
|
||||
<h2 className="text-lg font-semibold">Relatórios deste dossier</h2>
|
||||
<p>
|
||||
Dossier {dossier.case.title} · versão {dossier.case.version} ·
|
||||
oportunidade {dossier.context_opportunity ?? 'contexto comum'}.
|
||||
</p>
|
||||
<p>
|
||||
Reúna as etapas, a viabilidade, a análise SWOT e a preparação
|
||||
APIEX. O relatório mantém os estados e as pendências; os
|
||||
originais continuam nas respectivas versões.
|
||||
</p>
|
||||
{requiresChoice ? (
|
||||
<p role="status">
|
||||
Seleccione a oportunidade em tratamento para reunir os seus
|
||||
relatórios sem misturar contextos.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
className="rounded border px-3 py-2"
|
||||
disabled={busy}
|
||||
onClick={() => exportReport('word')}
|
||||
>
|
||||
Descarregar relatório consolidado (Word)
|
||||
</button>
|
||||
<button
|
||||
className="rounded border px-3 py-2"
|
||||
disabled={busy}
|
||||
onClick={() => exportReport('html')}
|
||||
>
|
||||
Descarregar versão para impressão/PDF
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
A versão HTML pode ser aberta e impressa ou guardada
|
||||
como PDF. As descargas são privadas; não enviam
|
||||
processos ou mensagens a entidades externas.
|
||||
</p>
|
||||
{!!entries.length && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<caption className="sr-only">
|
||||
Índice por etapa, versão e data
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2">Etapa</th>
|
||||
<th className="p-2">
|
||||
Relatório ou documento
|
||||
</th>
|
||||
<th className="p-2">Versão e data</th>
|
||||
<th className="p-2">Estado</th>
|
||||
<th className="p-2">Consultar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id} className="border-t">
|
||||
<td className="p-2">
|
||||
{entry.stage}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{entry.title}
|
||||
</td>
|
||||
<td className="max-w-56 p-2 break-words">
|
||||
{entry.version} ·{' '}
|
||||
{entry.date ||
|
||||
'Data não registada'}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{entry.state}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<a
|
||||
className="underline"
|
||||
href={entry.href}
|
||||
>
|
||||
{entry.action}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!entries.length && (
|
||||
<p>
|
||||
Nenhum relatório ou documento produzido e autorizado
|
||||
neste âmbito. O relatório consolidado identifica as
|
||||
etapas e as pendências existentes.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
Os relatórios financeiros são apresentados apenas ao
|
||||
titular autorizado do rascunho. Consulte os documentos
|
||||
de cada acto antes de executar o investimento.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
import { pathStates, type PathResult } from './investor-path.ts';
|
||||
import { restoreFinancialToolsSnapshot } from './financial-calculators.ts';
|
||||
import { buildProjectDossierRows } from './project-dossier.ts';
|
||||
import type { SwotDraft } from './investment-preparation.ts';
|
||||
import type {
|
||||
DossierReportModel,
|
||||
DossierReportRow,
|
||||
} from './project-dossier-report.ts';
|
||||
|
||||
export type PreparationReport = {
|
||||
case_id: string;
|
||||
opportunity: string | null;
|
||||
version: number;
|
||||
context_revision: number;
|
||||
href: string;
|
||||
api_state: string;
|
||||
api_issues: string[];
|
||||
exports: {
|
||||
id: string;
|
||||
at: string;
|
||||
context_revision: number;
|
||||
fingerprint: string;
|
||||
historical: boolean;
|
||||
href: string;
|
||||
}[];
|
||||
external: {
|
||||
kind: string;
|
||||
date: string;
|
||||
reference: string;
|
||||
export_id: string;
|
||||
outcome?: string;
|
||||
}[];
|
||||
swot: SwotDraft;
|
||||
swot_review_required: boolean;
|
||||
};
|
||||
export type InvestorDossierReportInput = {
|
||||
case: { id: string; title: string; version: number };
|
||||
context_opportunity: string | null;
|
||||
opportunity_options: unknown[];
|
||||
investment_context: {
|
||||
revision: number;
|
||||
fields?: Record<string, string>;
|
||||
} | null;
|
||||
phases: Record<string, string>;
|
||||
steps: {
|
||||
code: string;
|
||||
title: string;
|
||||
phase: number;
|
||||
version?: number;
|
||||
state: string;
|
||||
reason: string;
|
||||
detail: {
|
||||
scope?: string;
|
||||
document_id?: string;
|
||||
authority?: string;
|
||||
reference?: string;
|
||||
decision_date?: string;
|
||||
recorded_at?: string;
|
||||
};
|
||||
}[];
|
||||
results: PathResult[];
|
||||
documents: {
|
||||
id: string;
|
||||
document_id?: string;
|
||||
file_name: string;
|
||||
status: string;
|
||||
malware_scan_status: string;
|
||||
version_no: number;
|
||||
sha256: string;
|
||||
}[];
|
||||
preparation_report?: PreparationReport | null;
|
||||
fiscal_reports?: {
|
||||
simulation_id: string;
|
||||
report_id: string;
|
||||
report_version_no?: number;
|
||||
report_sha256?: string;
|
||||
consented_at?: string;
|
||||
scope_state?: 'identified' | 'common' | 'unconfirmed';
|
||||
}[];
|
||||
land_report?: {
|
||||
id: string;
|
||||
version: number;
|
||||
updated_at: string | null;
|
||||
href: string;
|
||||
} | null;
|
||||
company_reports?: {
|
||||
comparison?: { revision: number; at: string };
|
||||
preparation?: { revision: number; at: string };
|
||||
guides?: Record<
|
||||
string,
|
||||
{ revision: number; at: string; slug: string; title: string }
|
||||
>;
|
||||
};
|
||||
};
|
||||
export type DossierReportIndexEntry = {
|
||||
id: string;
|
||||
stage: string;
|
||||
title: string;
|
||||
version: string;
|
||||
date: string;
|
||||
state: string;
|
||||
href: string;
|
||||
action: string;
|
||||
};
|
||||
const externalStates: Record<string, string> = {
|
||||
preparing: 'Em preparação',
|
||||
ready: 'Pronto para entrega',
|
||||
delivery_recorded: 'Entrega externa registada',
|
||||
decision_received: 'Decisão recebida',
|
||||
};
|
||||
const externalRecords: Record<string, string> = {
|
||||
delivery: 'Entrega externa',
|
||||
clarification: 'Pedido de esclarecimento',
|
||||
response: 'Resposta recebida',
|
||||
decision: 'Decisão recebida',
|
||||
};
|
||||
const decisions: Record<string, string> = {
|
||||
approved: 'Favorável',
|
||||
refused: 'Desfavorável',
|
||||
conditional: 'Condicionada',
|
||||
other: 'Outra decisão',
|
||||
};
|
||||
const dimensions = {
|
||||
strength: 'Forças',
|
||||
weakness: 'Fraquezas',
|
||||
opportunity: 'Oportunidades',
|
||||
threat: 'Ameaças',
|
||||
};
|
||||
const classifications = {
|
||||
documented_fact: 'Facto documentado',
|
||||
hypothesis: 'Hipótese',
|
||||
unconfirmed: 'Por confirmar',
|
||||
};
|
||||
|
||||
function checkScope(input: InvestorDossierReportInput): void {
|
||||
if (input.opportunity_options.length > 1 && !input.context_opportunity)
|
||||
throw new Error(
|
||||
'Seleccione a oportunidade antes de reunir os relatórios.',
|
||||
);
|
||||
const preparation = input.preparation_report;
|
||||
if (
|
||||
preparation &&
|
||||
(preparation.case_id !== input.case.id ||
|
||||
preparation.opportunity !== input.context_opportunity)
|
||||
)
|
||||
throw new Error(
|
||||
'A preparação pertence a outro dossier ou oportunidade.',
|
||||
);
|
||||
}
|
||||
|
||||
function documentsInDossier(input: InvestorDossierReportInput) {
|
||||
const seen = new Set<string>();
|
||||
return input.documents.filter((document) => {
|
||||
const key = `${document.document_id ?? document.id}:${document.version_no}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function investorDossierReportIndex(
|
||||
input: InvestorDossierReportInput,
|
||||
): DossierReportIndexEntry[] {
|
||||
checkScope(input);
|
||||
const base = `/investimentos/${encodeURIComponent(input.case.id)}`;
|
||||
const query = input.context_opportunity
|
||||
? `?opportunity=${encodeURIComponent(input.context_opportunity)}`
|
||||
: '';
|
||||
const entries: DossierReportIndexEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (entry: DossierReportIndexEntry) => {
|
||||
if (!seen.has(entry.id)) {
|
||||
seen.add(entry.id);
|
||||
entries.push(entry);
|
||||
}
|
||||
};
|
||||
for (const result of input.results) {
|
||||
if ((result.context_opportunity ?? null) !== input.context_opportunity)
|
||||
continue;
|
||||
add({
|
||||
id: `finance:${result.id}`,
|
||||
stage: 'S03',
|
||||
title: 'Resultado de viabilidade financeira',
|
||||
version: String(result.draft_version),
|
||||
date: result.recorded_at,
|
||||
state:
|
||||
result.context_state === 'review_required' ||
|
||||
result.context_revision !== input.investment_context?.revision
|
||||
? 'Histórico — requer revisão'
|
||||
: 'Resultado guardado',
|
||||
href: `${base}${query}#resultados-financeiros`,
|
||||
action: 'Consultar resultado',
|
||||
});
|
||||
}
|
||||
for (const source of input.fiscal_reports ?? [])
|
||||
add({
|
||||
id: `fiscal:${source.report_id}`,
|
||||
stage: 'S03',
|
||||
title: 'Relatório de simulação fiscal',
|
||||
version: String(source.report_version_no ?? 'Por identificar'),
|
||||
date: source.consented_at ?? '',
|
||||
state:
|
||||
source.scope_state === 'identified'
|
||||
? 'Versão preservada — estimativa fiscal'
|
||||
: source.scope_state === 'common'
|
||||
? 'Versão preservada — contexto comum do dossier'
|
||||
: 'Versão preservada — âmbito por confirmar',
|
||||
href: `${base}/relatorios/fiscais/${encodeURIComponent(source.report_id)}${query}`,
|
||||
action: 'Descarregar versão preservada (JSON)',
|
||||
});
|
||||
for (const kind of ['comparison', 'preparation'] as const) {
|
||||
const saved = input.company_reports?.[kind];
|
||||
if (saved)
|
||||
add({
|
||||
id: `company:${kind}`,
|
||||
stage: 'S05',
|
||||
title:
|
||||
kind === 'comparison'
|
||||
? 'Comparação de formas jurídicas'
|
||||
: 'Preparação da empresa',
|
||||
version: String(saved.revision),
|
||||
date: saved.at,
|
||||
state: 'Preparação guardada',
|
||||
href: `${base}/${kind === 'comparison' ? 'forma-juridica' : 'preparar-empresa'}`,
|
||||
action: 'Consultar preparação',
|
||||
});
|
||||
}
|
||||
const preparation = input.preparation_report;
|
||||
if (input.land_report) {
|
||||
add({
|
||||
id: `land:${input.land_report.id}`,
|
||||
stage: 'S10',
|
||||
title: 'Preparação de acesso à terra',
|
||||
version: String(input.land_report.version),
|
||||
date: input.land_report.updated_at ?? '',
|
||||
state: 'Preparação guardada — não é título DUAT',
|
||||
href: input.land_report.href,
|
||||
action: 'Abrir versão para impressão',
|
||||
});
|
||||
}
|
||||
for (const exported of preparation?.exports ?? [])
|
||||
add({
|
||||
id: `apiex:${exported.id}`,
|
||||
stage: 'S06',
|
||||
title: 'Dossier documental APIEX',
|
||||
version: exported.id,
|
||||
date: exported.at,
|
||||
state: exported.historical
|
||||
? 'Exportação histórica preservada'
|
||||
: 'Exportação preparada',
|
||||
href: exported.href,
|
||||
action: 'Descarregar pacote',
|
||||
});
|
||||
if (preparation?.swot.rows.length)
|
||||
add({
|
||||
id: 'swot',
|
||||
stage: 'S02',
|
||||
title: 'Análise SWOT',
|
||||
version: String(preparation.version),
|
||||
date: preparation.swot.review?.at ?? '',
|
||||
state: preparation.swot_review_required
|
||||
? 'Revisão necessária'
|
||||
: 'Revista pela equipa',
|
||||
href: preparation.href,
|
||||
action: 'Consultar análise',
|
||||
});
|
||||
for (const guide of Object.values(input.company_reports?.guides ?? {}))
|
||||
add({
|
||||
id: `guide:${guide.slug}`,
|
||||
stage: 'S06',
|
||||
title: guide.title,
|
||||
version: String(guide.revision),
|
||||
date: guide.at,
|
||||
state: 'Referência guardada para o dossier',
|
||||
href: `${base}/guias/${encodeURIComponent(guide.slug)}`,
|
||||
action: 'Consultar guia',
|
||||
});
|
||||
for (const document of documentsInDossier(input)) {
|
||||
const stages = input.steps.filter(
|
||||
(step) => step.detail.document_id === document.document_id,
|
||||
);
|
||||
add({
|
||||
id: `document:${document.id}`,
|
||||
stage:
|
||||
stages.map((step) => step.code).join(', ') || 'Anexos comuns',
|
||||
title: document.file_name,
|
||||
version: String(document.version_no),
|
||||
date: '',
|
||||
state:
|
||||
document.malware_scan_status === 'clean' &&
|
||||
document.status === 'active'
|
||||
? 'Documento associado'
|
||||
: 'Disponibilidade por conferir',
|
||||
href: `/balcao-virtual/dossiers/${encodeURIComponent(input.case.id)}/documentos/${encodeURIComponent(document.id)}`,
|
||||
action: 'Consultar documento',
|
||||
});
|
||||
}
|
||||
for (const step of input.steps) {
|
||||
if (
|
||||
entries.some((entry) => entry.stage.split(', ').includes(step.code))
|
||||
)
|
||||
continue;
|
||||
add({
|
||||
id: `stage:${step.code}`,
|
||||
stage: step.code,
|
||||
title: step.title,
|
||||
version: String(step.version ?? 'Não registada'),
|
||||
date: step.detail.recorded_at ?? '',
|
||||
state:
|
||||
step.state === 'dispensed'
|
||||
? 'Preparação dispensada — consultar fundamento'
|
||||
: 'Sem relatório ou documento associado',
|
||||
href: `${base}${query}#${step.code}`,
|
||||
action: 'Consultar etapa',
|
||||
});
|
||||
}
|
||||
return entries.sort((a, b) => a.stage.localeCompare(b.stage));
|
||||
}
|
||||
|
||||
export function createInvestorDossierReport(
|
||||
input: InvestorDossierReportInput,
|
||||
generatedAt = new Date().toISOString(),
|
||||
): DossierReportModel {
|
||||
const index = investorDossierReportIndex(input);
|
||||
const row = (
|
||||
field: string,
|
||||
value: string,
|
||||
origin = 'Registo guardado neste dossier',
|
||||
classification = 'Preparação do investimento',
|
||||
): DossierReportRow => ({ field, value, origin, classification });
|
||||
const finance: DossierReportRow[] = [];
|
||||
for (const result of input.results.filter(
|
||||
(item) =>
|
||||
(item.context_opportunity ?? null) === input.context_opportunity,
|
||||
)) {
|
||||
const reference = `Resultado ${result.id}; rascunho ${result.draft_id}, versão ${result.draft_version}; ${result.recorded_at}`;
|
||||
const state =
|
||||
result.context_revision === input.investment_context?.revision &&
|
||||
result.context_state !== 'review_required'
|
||||
? 'Estimativa guardada'
|
||||
: 'Histórico — rever antes de utilizar';
|
||||
try {
|
||||
if (
|
||||
result.engine !== 'financial-calculators:v1' ||
|
||||
!restoreFinancialToolsSnapshot(result.financial_snapshot)
|
||||
)
|
||||
throw new Error('Versão não recuperável');
|
||||
const currency = result.currency ?? '';
|
||||
const baseline = new Set(
|
||||
buildProjectDossierRows({ currency }).map((item) =>
|
||||
JSON.stringify(item),
|
||||
),
|
||||
);
|
||||
const sections = new Set([
|
||||
'Plano financeiro',
|
||||
'Empréstimo educativo',
|
||||
'Ponto de equilíbrio',
|
||||
'Financiamento',
|
||||
'Orçamento',
|
||||
]);
|
||||
const rows = buildProjectDossierRows({
|
||||
currency,
|
||||
financial_tools_snapshot: result.financial_snapshot,
|
||||
});
|
||||
for (const item of rows.filter(
|
||||
(item) =>
|
||||
sections.has(item[0]) &&
|
||||
!baseline.has(JSON.stringify(item)),
|
||||
)) {
|
||||
finance.push(
|
||||
row(
|
||||
`${item[0]} — ${item[1]}`,
|
||||
item[2],
|
||||
`${reference}; ${item[3]}`,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
finance.push(
|
||||
row(
|
||||
'Resultado preservado',
|
||||
'Formato indisponível para cálculo; consulte a origem, sem substituir valores por zero.',
|
||||
reference,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const preparation = input.preparation_report;
|
||||
const swot = preparation?.swot;
|
||||
return {
|
||||
title: input.case.title,
|
||||
generatedAt,
|
||||
version: `Dossier ${input.case.id} · v${input.case.version} · contexto v${input.investment_context?.revision ?? 'não identificado'} · oportunidade ${input.context_opportunity ?? 'contexto comum'}`,
|
||||
references: [],
|
||||
limitations: [
|
||||
'Relatório privado de preparação; não comprova licenciamento, aprovação ou financiamento.',
|
||||
'Os actos externos só valem pelo respectivo documento e entidade. Pendências e versões históricas permanecem identificadas.',
|
||||
'O índice referencia os originais; não incorpora automaticamente os seus anexos nem envia dados a entidades externas.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
id: 'project',
|
||||
title: 'Investimento e localização em preparação',
|
||||
private: true,
|
||||
paragraphs: [
|
||||
'Dados guardados no contexto seleccionado; não representam aprovação ou validação externa.',
|
||||
],
|
||||
rows: Object.entries({
|
||||
project_title: 'Projecto',
|
||||
project_summary: 'Descrição',
|
||||
sector: 'Sector',
|
||||
province: 'Província',
|
||||
district: 'Distrito',
|
||||
location: 'Localização',
|
||||
total_investment: 'Investimento previsto',
|
||||
currency: 'Moeda',
|
||||
expected_start_date: 'Início previsto',
|
||||
})
|
||||
.filter(([key]) =>
|
||||
input.investment_context?.fields?.[key]?.trim(),
|
||||
)
|
||||
.map(([key, label]) =>
|
||||
row(
|
||||
label,
|
||||
input.investment_context?.fields?.[key] ?? '',
|
||||
`Contexto v${input.investment_context?.revision ?? 'não identificado'}`,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
title: 'Identificação e índice dos relatórios',
|
||||
private: true,
|
||||
paragraphs: [
|
||||
`Dossier: ${input.case.id}`,
|
||||
`Oportunidade: ${input.context_opportunity ?? 'Contexto comum do dossier'}`,
|
||||
`Gerado em ${generatedAt}; versão do dossier ${input.case.version}.`,
|
||||
],
|
||||
rows: index.map((entry) =>
|
||||
row(
|
||||
`${entry.stage} — ${entry.title}`,
|
||||
`${entry.state}; versão ${entry.version}; ${entry.date || 'data não registada'}`,
|
||||
entry.href,
|
||||
entry.action,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'execution',
|
||||
title: 'Etapas, resultados e pendências',
|
||||
private: true,
|
||||
paragraphs: [
|
||||
'A situação de uma etapa não significa que o respectivo relatório ou acto externo tenha sido produzido.',
|
||||
],
|
||||
rows: input.steps.map((step) =>
|
||||
row(
|
||||
`${step.code} — ${step.title}`,
|
||||
`${pathStates[step.state] ?? step.state}; versão ${step.version ?? 'não registada'}. ${step.reason || 'Sem fundamento adicional registado.'} ${step.detail.authority ?? ''} ${step.detail.reference ?? ''} ${step.detail.decision_date ?? ''}`,
|
||||
`Fase ${step.phase}: ${input.phases[String(step.phase)] ?? ''}`,
|
||||
step.state === 'not_started'
|
||||
? 'Pendente'
|
||||
: 'Estado guardado da etapa',
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
title: 'Viabilidade e relatórios fiscais',
|
||||
private: true,
|
||||
paragraphs: finance.length
|
||||
? [
|
||||
'Resultados recuperados das entradas guardadas pelo motor existente; impostos destinam-se à simulação de viabilidade.',
|
||||
]
|
||||
: [
|
||||
'Nenhum resultado financeiro devolvido e autorizado neste âmbito. Não foi gerado um valor em falta.',
|
||||
],
|
||||
rows: [
|
||||
...finance,
|
||||
...(input.fiscal_reports ?? []).map((source) =>
|
||||
row(
|
||||
`Relatório fiscal ${source.report_id}`,
|
||||
`Versão ${source.report_version_no ?? 'não indicada'}; SHA256 ${source.report_sha256 ?? 'não indicado'}`,
|
||||
`/investimentos/${input.case.id}/relatorios/fiscais/${source.report_id}${input.context_opportunity ? `?opportunity=${encodeURIComponent(input.context_opportunity)}` : ''}`,
|
||||
source.scope_state === 'identified'
|
||||
? 'Versão fiscal preservada'
|
||||
: source.scope_state === 'common'
|
||||
? 'Versão preservada no contexto comum do dossier'
|
||||
: 'Origem preservada no dossier; âmbito por confirmar',
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'market',
|
||||
title: 'Análise SWOT',
|
||||
private: true,
|
||||
paragraphs: swot?.rows.length
|
||||
? [
|
||||
`Objecto: ${swot.object_key}; âmbito: ${swot.scope ?? 'não indicado'}. ${preparation?.swot_review_required ? 'Revisão necessária.' : 'Revisão guardada.'}`,
|
||||
]
|
||||
: ['Análise SWOT ainda não produzida neste âmbito.'],
|
||||
rows: (swot?.rows ?? []).map((item) =>
|
||||
row(
|
||||
`${dimensions[item.dimension]} — ${item.text}`,
|
||||
`${classifications[item.classification]}; data ${item.date ?? 'por confirmar'}`,
|
||||
item.reference || 'Fonte por confirmar',
|
||||
classifications[item.classification],
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sources',
|
||||
title: 'Preparação documental APIEX e actos externos',
|
||||
private: true,
|
||||
paragraphs: preparation
|
||||
? [
|
||||
`Estado: ${externalStates[preparation.api_state] ?? preparation.api_state}. Exportar não equivale a entregar; entrega não equivale a aprovação.`,
|
||||
...preparation.api_issues,
|
||||
]
|
||||
: ['Preparação APIEX ainda não disponível neste âmbito.'],
|
||||
rows: (preparation?.external ?? []).map((item) =>
|
||||
row(
|
||||
`${externalRecords[item.kind] ?? item.kind} — ${item.date}`,
|
||||
`${item.reference}; ${item.outcome ? (decisions[item.outcome] ?? item.outcome) : 'sem decisão registada'}`,
|
||||
`Exportação ${item.export_id}`,
|
||||
'Registo de entrega ou resposta externa',
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
title: 'Documentos associados ao dossier',
|
||||
private: true,
|
||||
paragraphs: [
|
||||
'Consulte os originais no dossier. Os controlos de acesso e disponibilidade mantêm-se em cada descarga.',
|
||||
'Documentos comuns não comprovam a sua aplicabilidade a todas as oportunidades; confira a associação à etapa e ao pedido.',
|
||||
],
|
||||
rows: documentsInDossier(input).map((document) =>
|
||||
row(
|
||||
document.file_name,
|
||||
`Versão ${document.version_no}; SHA256 ${document.sha256}`,
|
||||
`/balcao-virtual/dossiers/${input.case.id}/documentos/${document.id}`,
|
||||
document.status === 'active' &&
|
||||
document.malware_scan_status === 'clean'
|
||||
? 'Documento associado'
|
||||
: 'Disponibilidade por conferir',
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { SectorResourceSelection } from '@/components/sector-resource-reference';
|
||||
import { InvestorDossierReports } from '@/components/investor-dossier-reports';
|
||||
import type { InvestorDossierReportInput } from '@/lib/investor-dossier-report';
|
||||
import {
|
||||
financialCategories,
|
||||
financialTypes,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
} from '@/lib/investor-path';
|
||||
|
||||
type Step = {
|
||||
version: number;
|
||||
code: string;
|
||||
title: string;
|
||||
phase: number;
|
||||
@@ -45,6 +48,10 @@ type Step = {
|
||||
source_paragraph: number;
|
||||
};
|
||||
type Path = {
|
||||
preparation_report?: InvestorDossierReportInput['preparation_report'];
|
||||
company_reports?: InvestorDossierReportInput['company_reports'];
|
||||
fiscal_reports?: InvestorDossierReportInput['fiscal_reports'];
|
||||
land_report?: InvestorDossierReportInput['land_report'];
|
||||
sector_references?: SectorResourceSelection[];
|
||||
funding_selections?: {
|
||||
id: string;
|
||||
@@ -117,6 +124,8 @@ type Path = {
|
||||
file_name: string;
|
||||
status: string;
|
||||
malware_scan_status: string;
|
||||
version_no: number;
|
||||
sha256: string;
|
||||
}[];
|
||||
requests: {
|
||||
id: string;
|
||||
@@ -995,7 +1004,8 @@ export default function InvestmentPath(path: Path) {
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
<section className="space-y-3">
|
||||
<InvestorDossierReports dossier={path} />
|
||||
<section id="resultados-financeiros" className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
Resultados devolvidos ao dossier
|
||||
</h2>
|
||||
|
||||
@@ -24,6 +24,7 @@ Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class, Virtual
|
||||
Route::get('/fontes-contexto', [InvestorContextController::class, 'options']);
|
||||
Route::get('/opcoes-corredor', [InvestorContextController::class, 'corridorOptions']);
|
||||
Route::get('/{id}/preparacao', [InvestmentPreparationController::class, 'show'])->whereUuid('id');
|
||||
Route::get('/{id}/relatorios/fiscais/{reportId}', [Path::class, 'fiscalReport'])->whereUuid(['id', 'reportId']);
|
||||
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');
|
||||
|
||||
@@ -71,6 +71,62 @@ class InvestmentPreparationWorkspaceTest extends TestCase
|
||||
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_dossier_reports_recover_preserved_exports_and_keep_financial_sources_private(): void
|
||||
{
|
||||
$view = $this->workspace();
|
||||
$view = $this->action($view, 'apiex_review', ['fingerprint' => $view['api_fingerprint']]);
|
||||
$view = $this->action($view, 'apiex_export');
|
||||
$export = $view['apiex']['exports'][0]['id'];
|
||||
$draft = (string) Str::uuid(); $report = (string) Str::uuid(); $simulation = (string) Str::uuid();
|
||||
$source = ['simulation_id' => $simulation, 'report_id' => $report, 'report_version_no' => 2, 'report_sha256' => str_repeat('a', 64), 'report_snapshot' => ['private' => 'SYNTHETIC NEVER EXPOSED'], 'consented_at' => now()->toIso8601String()];
|
||||
$otherReport = (string) Str::uuid();
|
||||
$otherSource = [...$source, 'report_id' => $otherReport, 'association_opportunity' => 'other-opportunity'];
|
||||
\App\Models\SimulationFormDraft::create(['id' => $draft, 'owner_user_id' => $this->owner->id,
|
||||
'create_key_hash' => hash('sha256', $draft), 'create_payload_hash' => hash('sha256', $draft), 'snapshot_sha256' => hash('sha256', $draft),
|
||||
'personal_snapshot' => \App\Models\SimulationFormDraft::encryptSnapshot([]),
|
||||
'economic_snapshot' => \App\Models\SimulationFormDraft::encryptSnapshot(['purpose' => 'request_support', 'simulation_sources' => [$source, $otherSource]])]);
|
||||
DB::table('desk_cases')->where('id', $this->caseId)->update(['simulation_id' => $draft]);
|
||||
$land = \App\Models\LandAssistanceCase::create(['user_id' => $this->owner->id, 'reference' => 'QA-'.Str::uuid(), 'crm_submission_key' => (string) Str::uuid(),
|
||||
'version' => 3, 'payload' => ['investor_path_case_id' => $this->caseId]]);
|
||||
$before = DB::table('simulation_form_drafts')->where('id', $draft)->first();
|
||||
$data = $this->getJson('/investimentos/'.$this->caseId)->assertOk()->json();
|
||||
$this->assertSame($this->caseId, $data['preparation_report']['case_id']);
|
||||
$this->assertSame($export, $data['preparation_report']['exports'][0]['id']);
|
||||
$this->assertStringContainsString('/investimentos/'.$this->caseId.'/preparacao/exportar/'.$export, $data['preparation_report']['exports'][0]['href']);
|
||||
$this->assertSame($report, $data['fiscal_reports'][0]['report_id']);
|
||||
$this->assertCount(1, $data['fiscal_reports']);
|
||||
$this->assertSame('/investidor/terra-duat/'.$land->id.'/imprimir', $data['land_report']['href']);
|
||||
$this->assertStringNotContainsString('SYNTHETIC NEVER EXPOSED', json_encode($data, JSON_THROW_ON_ERROR));
|
||||
$download = $this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$report)->assertOk()
|
||||
->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('case_id', $this->caseId)->assertJsonPath('report_snapshot.private', 'SYNTHETIC NEVER EXPOSED')->assertJsonPath('scope_state', 'unconfirmed');
|
||||
$this->assertSame($download->getContent(), $this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$report)->assertOk()->getContent());
|
||||
$this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.Str::uuid())->assertNotFound();
|
||||
$this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$otherReport)->assertNotFound();
|
||||
$this->assertEquals($before, DB::table('simulation_form_drafts')->where('id', $draft)->first());
|
||||
$this->getJson('/investimentos/'.$this->caseId.'?opportunity=other-opportunity')->assertUnprocessable();
|
||||
$this->getJson('/investimentos/'.$this->caseId)->assertOk()->assertJsonPath('preparation_report.exports.0.id', $export)->assertJsonPath('fiscal_reports.0.report_id', $report);
|
||||
$drafts = app(\App\Services\SimulationFormDrafts::class);
|
||||
$current = \App\Models\SimulationFormDraft::findOrFail($draft);
|
||||
$request = \Illuminate\Http\Request::create('/api/v1/simulation-drafts/'.$draft.'/simulations', 'POST');
|
||||
$request->setUserResolver(fn () => $this->owner);
|
||||
$request->headers->set('If-Match', $drafts->etag($current));
|
||||
$newSource = [...$source, 'simulation_id' => (string) Str::uuid(), 'report_id' => (string) Str::uuid()];
|
||||
\App\Models\Simulation::create(['id' => $newSource['simulation_id'], 'owner_user_id' => $this->owner->id, 'status' => 'completed', 'input_snapshot' => ['synthetic' => true]]);
|
||||
$attached = $drafts->attachSimulation($draft, $newSource, $request);
|
||||
$saved = \App\Models\SimulationFormDraft::decryptSnapshot($attached['draft']->economic_snapshot)['simulation_sources'];
|
||||
$this->assertSame($source, $saved[0]);
|
||||
$this->assertArrayHasKey('association_opportunity', $saved[2]);
|
||||
$this->assertNull($saved[2]['association_opportunity']);
|
||||
$this->assertTrue($drafts->attachSimulation($draft, $newSource, $request)['replayed']);
|
||||
config(['investor-path.enabled' => false]);
|
||||
$this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$report)->assertNotFound();
|
||||
config(['investor-path.enabled' => true]);
|
||||
$manager = User::factory()->create(); config(['portal.managers' => [$manager->email]]);
|
||||
$this->actingAs($manager)->getJson('/investimentos/'.$this->caseId)->assertOk()->assertJsonPath('fiscal_reports', [])->assertJsonPath('results', [])->assertJsonPath('land_report', null);
|
||||
$this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$report)->assertForbidden();
|
||||
$this->actingAs(User::factory()->create())->getJson('/investimentos/'.$this->caseId)->assertForbidden();
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import JSZip from 'jszip';
|
||||
import {
|
||||
createInvestorDossierReport,
|
||||
investorDossierReportIndex,
|
||||
} from '../../resources/js/lib/investor-dossier-report.ts';
|
||||
import {
|
||||
createProjectDossierPrintHtml,
|
||||
generateProjectDossierDocx,
|
||||
} from '../../resources/js/lib/project-dossier-report.ts';
|
||||
import { serializeFinancialToolsSnapshot } from '../../resources/js/lib/financial-calculators.ts';
|
||||
|
||||
function fixture() {
|
||||
return {
|
||||
case: {
|
||||
id: 'case-a',
|
||||
title: 'Investimento QA <script>alert(1)</script>',
|
||||
version: 9,
|
||||
},
|
||||
context_opportunity: 'op-a',
|
||||
opportunity_options: ['op-a', 'op-b'],
|
||||
investment_context: {
|
||||
revision: 3,
|
||||
fields: { project_title: 'Projecto QA', province: 'Manica' },
|
||||
},
|
||||
phases: { 1: 'Viabilidade', 2: 'Preparação' },
|
||||
steps: [
|
||||
{
|
||||
code: 'S03',
|
||||
title: 'Viabilidade',
|
||||
phase: 1,
|
||||
version: 2,
|
||||
state: 'preparing',
|
||||
reason: 'Conferir pressupostos',
|
||||
detail: {},
|
||||
},
|
||||
{
|
||||
code: 'S08',
|
||||
title: 'Licenciamento prévio',
|
||||
phase: 2,
|
||||
version: 4,
|
||||
state: 'not_started',
|
||||
reason: '',
|
||||
detail: { document_id: 'doc-a' },
|
||||
},
|
||||
],
|
||||
results: [],
|
||||
land_report: {
|
||||
id: 'land-a',
|
||||
version: 3,
|
||||
updated_at: '2026-11-13',
|
||||
href: '/investidor/terra-duat/land-a/imprimir',
|
||||
},
|
||||
documents: [
|
||||
{
|
||||
id: 'link-a',
|
||||
document_id: 'doc-a',
|
||||
file_name: 'Estudo QA',
|
||||
status: 'active',
|
||||
malware_scan_status: 'clean',
|
||||
version_no: 2,
|
||||
sha256: 'a'.repeat(64),
|
||||
},
|
||||
],
|
||||
preparation_report: {
|
||||
case_id: 'case-a',
|
||||
opportunity: 'op-a',
|
||||
version: 7,
|
||||
context_revision: 3,
|
||||
href: '/investimentos/case-a/preparacao?opportunity=op-a',
|
||||
api_state: 'delivery_recorded',
|
||||
api_issues: [],
|
||||
exports: [
|
||||
{
|
||||
id: 'export-old',
|
||||
at: '2026-11-12',
|
||||
context_revision: 2,
|
||||
historical: true,
|
||||
fingerprint: 'old',
|
||||
href: '/investimentos/case-a/preparacao/exportar/export-old?opportunity=op-a',
|
||||
},
|
||||
{
|
||||
id: 'export-new',
|
||||
at: '2026-11-13',
|
||||
context_revision: 3,
|
||||
historical: false,
|
||||
fingerprint: 'new',
|
||||
href: '/investimentos/case-a/preparacao/exportar/export-new?opportunity=op-a',
|
||||
},
|
||||
],
|
||||
external: [
|
||||
{
|
||||
kind: 'delivery',
|
||||
date: '2026-11-13',
|
||||
reference: 'QA-ENTREGA',
|
||||
export_id: 'export-new',
|
||||
},
|
||||
],
|
||||
swot_review_required: true,
|
||||
swot: {
|
||||
object_key: 'investment:case-a',
|
||||
scope: 'Produção QA',
|
||||
rows: [
|
||||
{
|
||||
id: 's1',
|
||||
dimension: 'strength',
|
||||
text: 'Equipa experiente',
|
||||
reference: 'Fonte QA',
|
||||
date: '2026-11-12',
|
||||
classification: 'hypothesis',
|
||||
},
|
||||
],
|
||||
review: null,
|
||||
actions: [],
|
||||
},
|
||||
},
|
||||
fiscal_reports: [
|
||||
{
|
||||
simulation_id: 'simulation-a',
|
||||
report_id: 'report-a',
|
||||
report_version_no: 2,
|
||||
report_sha256: 'b'.repeat(64),
|
||||
consented_at: '2026-11-13',
|
||||
},
|
||||
],
|
||||
company_reports: {
|
||||
comparison: { revision: 2, at: '2026-11-12' },
|
||||
guides: {
|
||||
energy: {
|
||||
slug: 'energia',
|
||||
title: 'Guia de energia',
|
||||
revision: 1,
|
||||
at: '2026-11-12',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void test('one dossier groups stage evidence, SWOT, fiscal and all preserved APIEX versions without modifying sources', () => {
|
||||
const input = fixture();
|
||||
input.documents.push({ ...input.documents[0], id: 'second-link' });
|
||||
const before = JSON.stringify(input);
|
||||
const index = investorDossierReportIndex(input);
|
||||
assert.equal(
|
||||
index.filter((entry) => entry.id.startsWith('apiex:')).length,
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
index.find((entry) => entry.id === 'document:link-a').stage,
|
||||
'S08',
|
||||
);
|
||||
assert.equal(
|
||||
index.filter((entry) => entry.id.startsWith('document:')).length,
|
||||
1,
|
||||
);
|
||||
assert.match(
|
||||
index.find((entry) => entry.id === 'apiex:export-old').state,
|
||||
/histórica/,
|
||||
);
|
||||
assert.equal(
|
||||
index.find((entry) => entry.id === 'swot').state,
|
||||
'Revisão necessária',
|
||||
);
|
||||
assert.match(
|
||||
index.find((entry) => entry.id === 'land:land-a').state,
|
||||
/não é título DUAT/,
|
||||
);
|
||||
const report = createInvestorDossierReport(input, '2026-11-13T12:00:00Z');
|
||||
assert.match(report.version, /case-a.*v9.*contexto v3.*op-a/);
|
||||
assert.match(
|
||||
report.sections.find((s) => s.id === 'execution').rows[1].value,
|
||||
/Por iniciar.*versão 4/,
|
||||
);
|
||||
assert.match(
|
||||
report.sections.find((s) => s.id === 'market').rows[0].value,
|
||||
/Hipótese/,
|
||||
);
|
||||
assert.match(
|
||||
report.sections.find((s) => s.id === 'sources').paragraphs.join(' '),
|
||||
/Entrega externa registada.*não equivale a aprovação/,
|
||||
);
|
||||
assert.equal(JSON.stringify(input), before);
|
||||
});
|
||||
|
||||
void test('cross-dossier or cross-opportunity preparation is rejected and an ambiguous scope requires selection', () => {
|
||||
const input = fixture();
|
||||
input.preparation_report.case_id = 'case-b';
|
||||
assert.throws(() => createInvestorDossierReport(input), /outro dossier/);
|
||||
input.preparation_report.case_id = 'case-a';
|
||||
input.preparation_report.opportunity = 'op-b';
|
||||
assert.throws(() => createInvestorDossierReport(input), /oportunidade/);
|
||||
input.context_opportunity = null;
|
||||
assert.throws(() => investorDossierReportIndex(input), /Seleccione/);
|
||||
});
|
||||
|
||||
void test('financial results from a different opportunity do not enter the consolidated report or its index', () => {
|
||||
const input = fixture();
|
||||
input.results = [
|
||||
{
|
||||
id: 'other',
|
||||
context_opportunity: 'op-b',
|
||||
financial_snapshot: 'OTHER-PRIVATE-INPUTS',
|
||||
draft_id: 'other-draft',
|
||||
draft_version: 8,
|
||||
recorded_at: '2026-11-13',
|
||||
engine: 'unknown',
|
||||
currency: 'MZN',
|
||||
},
|
||||
];
|
||||
assert.ok(
|
||||
!JSON.stringify(createInvestorDossierReport(input)).includes(
|
||||
'OTHER-PRIVATE-INPUTS',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!investorDossierReportIndex(input).some(
|
||||
(entry) => entry.id === 'finance:other',
|
||||
),
|
||||
);
|
||||
assert.match(
|
||||
createInvestorDossierReport(input).sections.find(
|
||||
(s) => s.id === 'finance',
|
||||
).paragraphs[0],
|
||||
/Nenhum resultado/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('the consolidated report reuses loan, break-even, financing and budget outputs with currency and historical state', () => {
|
||||
const input = fixture();
|
||||
const financial_snapshot = serializeFinancialToolsSnapshot({
|
||||
budgetPeriod: 'Mensal',
|
||||
loan: {
|
||||
principal: '100000',
|
||||
rate: '0',
|
||||
months: '12',
|
||||
rateType: 'nominal',
|
||||
periodMonths: '1',
|
||||
gracePeriods: '0',
|
||||
upfrontFee: '0',
|
||||
recurringFee: '0',
|
||||
},
|
||||
breakEven: { fixed: '1000', price: '100', variable: '50' },
|
||||
funding: { total: '100000', secured: '30000' },
|
||||
incomes: [{ label: 'Receita QA', amount: '100' }],
|
||||
expenses: [{ label: 'Custo QA', amount: '50' }],
|
||||
});
|
||||
input.results = [
|
||||
{
|
||||
id: 'finance-a',
|
||||
context_opportunity: 'op-a',
|
||||
context_revision: 2,
|
||||
context_state: 'review_required',
|
||||
financial_snapshot,
|
||||
draft_id: 'draft-a',
|
||||
draft_version: 5,
|
||||
recorded_at: '2026-11-12',
|
||||
engine: 'financial-calculators:v1',
|
||||
currency: 'MZN',
|
||||
},
|
||||
];
|
||||
const report = createInvestorDossierReport(input);
|
||||
const rows = report.sections.find(
|
||||
(section) => section.id === 'finance',
|
||||
).rows;
|
||||
assert.equal(
|
||||
rows.find(
|
||||
(item) => item.field === 'Financiamento — Diferença por financiar',
|
||||
).value,
|
||||
'70000.00 MZN',
|
||||
);
|
||||
assert.equal(
|
||||
rows.find(
|
||||
(item) => item.field === 'Ponto de equilíbrio — Unidades estimadas',
|
||||
).value,
|
||||
'20',
|
||||
);
|
||||
assert.equal(
|
||||
rows.find(
|
||||
(item) => item.field === 'Orçamento — Saldo operacional estimado',
|
||||
).value,
|
||||
'50.00 MZN',
|
||||
);
|
||||
assert.ok(
|
||||
rows.some((item) => item.field.startsWith('Empréstimo educativo')),
|
||||
);
|
||||
assert.match(rows[0].classification, /Histórico/);
|
||||
assert.equal(input.results[0].financial_snapshot, financial_snapshot);
|
||||
});
|
||||
|
||||
void test('the existing Word and print exporters retain dossier identity, pending stages, source and history, escaping active HTML', async () => {
|
||||
const report = createInvestorDossierReport(
|
||||
fixture(),
|
||||
'2026-11-13T12:00:00Z',
|
||||
);
|
||||
const html = await createProjectDossierPrintHtml(report, [], false);
|
||||
assert.ok(!html.includes('<script>alert(1)</script>'));
|
||||
assert.match(html, /case-a/);
|
||||
assert.match(html, /Fonte QA/);
|
||||
assert.match(html, /export-old/);
|
||||
assert.ok(html.includes('a'.repeat(64)));
|
||||
const docx = await generateProjectDossierDocx(report, [], false);
|
||||
const zip = await JSZip.loadAsync(await docx.arrayBuffer());
|
||||
const xml = await zip.file('word/document.xml').async('string');
|
||||
assert.match(xml, /case-a/);
|
||||
assert.match(xml, /Licenciamento prévio/);
|
||||
assert.match(xml, /Por iniciar/);
|
||||
assert.match(xml, /Fonte QA/);
|
||||
assert.match(xml, /export-old/);
|
||||
});
|
||||
Reference in New Issue
Block a user