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
@@ -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>
);
}
+554
View File
@@ -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',
),
),
},
],
};
}
+11 -1
View File
@@ -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>