Files
Investidor-V2/resources/js/lib/project-dossier-report.ts
T

568 lines
23 KiB
TypeScript

import { displayValue } from './display-value.ts';
import {
buildProjectDossierRows,
type ProjectDossierRow,
} from './project-dossier.ts';
export const dossierReportSections = [
{ id: 'summary', title: 'Resumo executivo', private: true },
{
id: 'project',
title: 'Identificação e descrição do projecto',
private: true,
},
{ id: 'market', title: 'Mercado e hipóteses', private: true },
{ id: 'location', title: 'Localização', private: true },
{ id: 'finance', title: 'Plano financeiro e cálculos', private: true },
{ id: 'team', title: 'Equipa, competências e lacunas', private: true },
{ id: 'resources', title: 'Recursos e financiamento', private: true },
{
id: 'risk',
title: 'O que precisa de esclarecer: riscos e respostas',
private: true,
},
{ id: 'execution', title: 'Marcos e execução', private: true },
{
id: 'sources',
title: 'Fontes, pendências e próximos passos',
private: true,
},
{ id: 'annexes', title: 'Anexos seleccionados', private: true },
] as const;
export type DossierReportSectionId =
(typeof dossierReportSections)[number]['id'];
export type DossierReference = {
version: number;
date: string;
checksum: string;
};
export type DossierCurrentCalculation = {
status: string;
version: number;
updatedAt?: string;
snapshot: Record<string, unknown> | null;
reportChecksum?: string;
};
export type DossierAttachment = {
file: File;
include: boolean;
caption: string;
};
export type DossierAttachmentKind = 'image' | 'csv' | 'text' | 'original-only';
export type DossierReportRow = {
field: string;
value: string;
origin: string;
classification: string;
};
export type DossierReportSection = {
id: DossierReportSectionId;
title: string;
private: boolean;
paragraphs: string[];
rows: DossierReportRow[];
};
export type DossierReportModel = {
title: string;
generatedAt: string;
version: string;
sections: DossierReportSection[];
references: DossierReference[];
limitations: string[];
};
export const MAX_DOSSIER_ATTACHMENT_COUNT = 10;
export const MAX_DOSSIER_ATTACHMENT_BYTES = 15 * 1024 * 1024;
export const MAX_DOSSIER_ATTACHMENT_TOTAL_BYTES = 50 * 1024 * 1024;
export const MAX_EMBEDDED_TEXT_BYTES = 2 * 1024 * 1024;
function text(draft: Record<string, string | string[]>, key: string): string {
const value = draft[key];
if (Array.isArray(value)) return value.filter(Boolean).join(', ');
return typeof value === 'string' ? value.trim() : '';
}
function absent(value: string): string {
return value.trim() === '' ? 'Não informado' : value;
}
function classification(origin: string): string {
const normal = origin.toLocaleLowerCase('pt-MZ');
if (normal.includes('não verificada') || normal.includes('nao verificada'))
return 'Informação declarada, não verificada';
if (normal.includes('estimativa') || normal.includes('estimado'))
return 'Estimativa';
if (
normal.includes('indeterminado') ||
normal.includes('insuficiente') ||
normal.includes('não informado')
)
return 'Indeterminado';
if (normal.includes('catálogo') || normal.includes('catalogo'))
return 'Referência de catálogo; não equivale a verificação documental';
if (
normal.includes('utilizador') ||
normal.includes('declarada') ||
normal.includes('declarado') ||
normal.includes('pressuposto')
)
return 'Informação declarada ou pressuposto';
return 'Referência/estado indicado';
}
export function formatDossierReportValue(field: string, value: string, currency = ''): string {
if (field === 'Estado do plano detalhado') {
return value.replace('Snapshot versionado disponível; usar também a exportação do plano financeiro', 'Plano guardado disponível; consulte também a exportação financeira');
}
if (field === 'Reconciliação do financiamento') {
const states: Record<string, string> = { shortfall: 'Financiamento insuficiente', surplus: 'Fontes superiores aos usos previstos', balanced: 'Fontes e usos equilibrados', unknown: 'Informação insuficiente', incomplete: 'Informação insuficiente' };
return states[value] ?? value;
}
if (/^Necessidade \d+$/.test(field)) {
value = value.replace('; gap;', '; Competência em falta;').replace('; unknown;', '; Por confirmar;').replace('; available;', '; Disponível (declarado);');
}
if (/^Marco \d+$/.test(field)) {
value = value.replace('; planned;', '; Planeado;').replace('; in_progress;', '; Em curso;').replace('; completed;', '; Concluído (declarado);');
}
if (/^(Necessidade|Marco) \d+$/.test(field)) {
value = value.replace(/(custo|planeado|realizado): ([^;]*)(?=;|$)/g, (_, label: string, amount: string) => {
const raw = amount.trim();
return `${label}: ${raw ? (/^\d+(?:\.\d{1,2})?$/.test(raw) ? `${raw} ${currency || '(moeda não informada)'}` : raw) : 'Não informado'}`;
}).replace(/prazo: (?=;|$)/g, 'prazo: Não informado');
}
// Presentation only: group the decimal string without converting the amount
// to a JavaScript float or changing the saved financial snapshot.
return value.replace(/^(-?)(\d+)\.(\d{2}) ([A-Z]{3})$/, (_, sign: string, whole: string, cents: string, currency: string) => `${sign}${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')},${cents} ${currency}`);
}
const rowSection: Record<string, DossierReportSectionId> = {
Projecto: 'project',
Mercado: 'market',
Localização: 'location',
'Plano financeiro': 'finance',
'Empréstimo educativo': 'finance',
'Ponto de equilíbrio': 'finance',
Orçamento: 'finance',
Equipa: 'team',
'Necessidade de negócio': 'resources',
Financiamento: 'resources',
Risco: 'risk',
Execução: 'execution',
};
function makeSections(
draft: Record<string, string | string[]>,
rows: ProjectDossierRow[],
selectedSections: ReadonlySet<DossierReportSectionId>,
references: DossierReference[],
currentCalculation: DossierCurrentCalculation | null,
generatedAt: string,
version: string,
): DossierReportSection[] {
const sections = new Map<DossierReportSectionId, DossierReportSection>();
for (const section of dossierReportSections) {
if (!selectedSections.has(section.id)) continue;
sections.set(section.id, {
id: section.id,
title: section.title,
private: section.private,
paragraphs: [],
rows: [],
});
}
const summary = sections.get('summary');
if (summary) {
const suppliedSummary = text(draft, 'project_summary');
const summaryParts = [
suppliedSummary,
!suppliedSummary && text(draft, 'project_title')
? `Projecto: ${text(draft, 'project_title')}.`
: '',
!suppliedSummary && text(draft, 'project_problem')
? `Necessidade abordada: ${text(draft, 'project_problem')}.`
: '',
!suppliedSummary && text(draft, 'value_proposition')
? `Proposta de valor declarada: ${text(draft, 'value_proposition')}.`
: '',
!suppliedSummary && text(draft, 'target_customers')
? `Clientes/beneficiários indicados: ${text(draft, 'target_customers')}.`
: '',
].filter(Boolean);
summary.paragraphs.push(
summaryParts.length
? summaryParts.join(' ')
: 'Resumo executivo pendente: ainda não foram introduzidas informações suficientes no dossier.',
);
summary.rows.push(
{
field: 'Projecto',
value: absent(text(draft, 'project_title')),
origin: 'Informação introduzida no dossier',
classification: 'Declarada pelo utilizador',
},
{
field: 'Data de geração',
value: new Date(generatedAt).toLocaleString('pt-MZ'),
origin: 'Emissão no momento da exportação',
classification: 'Data de emissão',
},
{
field: 'Versão/revisão do dossier',
value: version,
origin: 'Revisão do dossier exportado',
classification: 'Referência da revisão',
},
);
}
for (const [sectionName, field, value, origin] of rows.slice(1)) {
// Questionnaire schema identifiers remain stored; they do not help the
// reader assess the project and are not a business report field.
if (field === 'Versão do questionário') continue;
const id = rowSection[sectionName];
if (!id) continue;
const section = sections.get(id);
if (!section) continue;
section.rows.push({
field,
value: absent(formatDossierReportValue(field, value, text(draft, 'currency'))),
origin,
classification: value.trim() === '' ? 'Informação em falta' : classification(origin),
});
}
const project = sections.get('project');
if (project) {
const extra = [
['Data prevista de início', text(draft, 'expected_start_date')],
['Duração prevista', text(draft, 'project_duration_years')],
];
for (const [field, value] of extra) {
if (!project.rows.some((row) => row.field === field))
project.rows.push({
field,
value: absent(value),
origin: 'Informação do dossier',
classification: value
? 'Declarada pelo utilizador'
: 'Indeterminado',
});
}
}
const location = sections.get('location');
if (location) {
for (const [field, key] of [
['País', 'country_id_label'],
['Província', 'province_id_label'],
['Distrito', 'district_id_label'],
['Posto administrativo', 'admin_post_id_label'],
['Localidade', 'locality_id_label'],
]) {
if (!location.rows.some((row) => row.field === field)) {
const value =
text(draft, key) ||
(key === 'country_id_label' &&
text(draft, 'country_id') === 'MZ'
? 'Moçambique'
: '');
location.rows.push({
field,
value: absent(value),
origin: 'Selecção/informação territorial do dossier',
classification: value
? 'Selecção declarada; não constitui verificação do local'
: 'Indeterminado',
});
}
}
}
const finance = sections.get('finance');
if (finance && currentCalculation) {
const snapshot = currentCalculation.snapshot;
const result =
snapshot &&
typeof snapshot.result === 'object' &&
snapshot.result !== null &&
!Array.isArray(snapshot.result)
? (snapshot.result as Record<string, unknown>)
: null;
const context =
snapshot &&
typeof snapshot.context === 'object' &&
snapshot.context !== null &&
!Array.isArray(snapshot.context)
? (snapshot.context as Record<string, unknown>)
: null;
const rawValue = result?.value ?? snapshot?.indicative_total;
const knownValue =
typeof rawValue === 'string' && /^(?:\d+)(?:\.\d+)?$/.test(rawValue)
? rawValue
: '';
finance.rows.push(
{
field: 'Estado da simulação fiscal/aritmética anexada',
value:
currentCalculation.status === 'completed'
? result?.status === 'indeterminate'
? 'Indeterminado'
: 'Concluída'
: 'Não concluída; resultado não incluído como confirmado',
origin: 'Execução existente no simulador',
classification:
currentCalculation.status === 'completed'
? 'Estado da execução; verificar a explicação e o âmbito abaixo'
: 'Indeterminado',
},
{
field: 'Resultado indicativo',
value:
currentCalculation.status === 'completed' && knownValue
? `${knownValue}${typeof result?.unit === 'string' ? ` ${result.unit}` : ''}`
: 'Indeterminado/não disponível',
origin: 'Snapshot da execução existente; valor reproduzido sem recalcular',
classification:
'Resultado do simulador; não é decisão fiscal nem aconselhamento',
},
{
field: 'Explicação e dados em falta',
value:
typeof result?.explanation === 'string'
? result.explanation
: 'Não disponível no snapshot',
origin: 'Explicação guardada pela execução',
classification: 'Informação do resultado do simulador',
},
{
field: 'Data de referência',
value:
typeof context?.reference_date === 'string'
? context.reference_date
: 'Não identificada',
origin: 'Contexto guardado na execução',
classification: 'Metadado da simulação',
},
{
field: 'Versão do motor/cenário',
value: `${typeof context?.engine_version === 'string' ? context.engine_version : 'não identificada'} / ${typeof context?.scenario_version === 'number' ? context.scenario_version : currentCalculation.version}`,
origin: 'Contexto guardado na execução',
classification: 'Referência da revisão',
},
{
field: 'Fórmula e valores usados',
value:
snapshot && typeof snapshot.formula_version === 'string'
? `Fórmula ${snapshot.formula_version}; base ${displayValue(snapshot.base, 'não disponível')}; taxa ${displayValue(snapshot.rate_percent, 'não disponível')}%; redução ${displayValue(snapshot.reduction_percent, 'não disponível')}%; moeda ${displayValue(snapshot.currency, 'não indicada')}`
: 'A fórmula não foi identificada no snapshot; ver explicação da execução',
origin: 'Snapshot guardado; sem aplicação de regra legal',
classification: 'Reutilização do resultado existente',
},
{
field: 'Revisão do resultado',
value: String(currentCalculation.version),
origin: currentCalculation.updatedAt ?? 'Data não identificada',
classification: 'Versão imutável da execução existente',
},
...(currentCalculation.reportChecksum
? [
{
field: 'Hash SHA-256 do relatório associado',
value: currentCalculation.reportChecksum,
origin: 'Referência imutável do relatório',
classification:
'Integridade técnica; não é fonte legal',
},
]
: []),
);
}
if (finance && !text(draft, 'business_plan_snapshot')) {
finance.paragraphs.push(
'Não existe plano empresarial detalhado guardado neste dossier. Os campos sem valor são indicados como “Não informado”; não se substituem por zero.',
);
}
const resources = sections.get('resources');
if (resources) {
resources.rows.push({
field: 'Pré-elegibilidade de financiamento',
value: 'Indeterminada; não há catálogo de produtos e critérios documentados para esta consulta.',
origin: 'Limite funcional conhecido',
classification: 'Não verificado/não decidido',
});
const needs = text(draft, 'infrastructure_needs');
resources.rows.push({
field: 'Outros recursos declarados',
value: absent(needs),
origin: 'Informação do dossier',
classification: needs
? 'Declarada pelo utilizador'
: 'Indeterminado',
});
}
const sources = sections.get('sources');
if (sources) {
const marketSource = text(draft, 'market_source');
sources.rows.push({
field: 'Origem de mercado',
value: absent(marketSource),
origin: 'Declarada pelo utilizador; não verificada pelo portal',
classification: marketSource
? 'Informação declarada, não verificada'
: 'Indeterminado',
});
if (!marketSource)
sources.paragraphs.push(
'Se disponível, registar origem e data da informação de mercado e validar a fonte antes de a usar numa decisão.',
);
if (references.length) {
for (const [index, ref] of references.entries()) {
sources.rows.push({
field: `Referência de cálculo anexada ${index + 1}`,
value: `Versão ${ref.version}; ${new Date(ref.date).toLocaleString('pt-MZ')}; SHA-256 ${ref.checksum}`,
origin: 'Simulação matemática anexada ao dossier com consentimento',
classification:
'Rasto de cálculo; não é fonte oficial externa',
});
}
} else {
sources.rows.push({
field: 'Cálculos de simulação anexados',
value: 'Nenhum cálculo externo/anterior anexado ao dossier.',
origin: 'Estado actual do dossier',
classification: 'Não aplicável',
});
}
const nextSteps = [
!text(draft, 'project_summary') &&
'Completar o resumo do projecto ou negócio.',
!text(draft, 'market_assumption') &&
'Descrever, se conhecido, a hipótese de mercado e a sua origem/data.',
!text(draft, 'business_plan_snapshot') &&
'Registar pressupostos financeiros se forem necessários à decisão.',
!text(draft, 'project_team_snapshot') &&
'Identificar competências/equipa em falta, caso sejam relevantes.',
'Rever os pressupostos e confirmar as fontes antes de tomar decisões.',
'Verificar critérios de financiamento em produtos documentados; esta exportação não determina elegibilidade.',
].filter((item): item is string => Boolean(item));
sources.paragraphs.push(
`Próximos passos sugeridos a partir dos campos em falta: ${nextSteps.join(' ')}`,
);
sources.paragraphs.push(
'Informação não carregada ou não fornecida não é inferida. A exportação não publica nem partilha o dossier.',
);
}
return [...sections.values()];
}
export function createProjectDossierReport(
draft: Record<string, string | string[]>,
options: {
selectedSections: ReadonlySet<DossierReportSectionId>;
version?: string;
references?: DossierReference[];
currentCalculation?: DossierCurrentCalculation | null;
generatedAt?: string;
},
): DossierReportModel {
const generatedAt = options.generatedAt ?? new Date().toISOString();
const references = options.references ?? [];
const reportRows = buildProjectDossierRows(draft);
const title = absent(text(draft, 'project_title'));
return {
title,
generatedAt,
version: options.version?.trim() || 'Rascunho sem revisão guardada',
sections: makeSections(
draft,
reportRows,
options.selectedSections,
references,
options.currentCalculation ?? null,
generatedAt,
options.version?.trim() || 'Rascunho sem revisão guardada',
),
references,
limitations: [
'Conteúdo fornecido pelo utilizador, estimativas matemáticas, selecções de catálogo e referências documentadas estão rotulados separadamente.',
'A origem de mercado introduzida pelo utilizador não foi verificada automaticamente.',
'Não há pré-aprovação nem confirmação de financiamento nesta exportação.',
'O relatório é gerado localmente e não é publicado nem partilhado pelo portal.',
],
};
}
export function classifyDossierAttachment(
file: Pick<File, 'name' | 'type' | 'size'>,
): DossierAttachmentKind {
const name = file.name.toLocaleLowerCase('en');
const type = file.type.toLocaleLowerCase('en');
if (
(type === 'image/jpeg' || type === 'image/png') &&
file.size <= MAX_DOSSIER_ATTACHMENT_BYTES
)
return 'image';
if (
(type === 'text/csv' ||
name.endsWith('.csv') ||
name.endsWith('.tsv')) &&
file.size <= MAX_EMBEDDED_TEXT_BYTES
)
return 'csv';
if (
(type === 'text/plain' || name.endsWith('.txt')) &&
file.size <= MAX_EMBEDDED_TEXT_BYTES
)
return 'text';
return 'original-only';
}
export function validateDossierAttachments(
files: readonly Pick<File, 'size'>[],
): string | null {
if (files.length > MAX_DOSSIER_ATTACHMENT_COUNT)
return `Seleccione no máximo ${MAX_DOSSIER_ATTACHMENT_COUNT} anexos.`;
if (files.some((file) => file.size > MAX_DOSSIER_ATTACHMENT_BYTES))
return 'Cada anexo deve ter até 15 MB.';
if (
files.reduce((total, file) => total + file.size, 0) >
MAX_DOSSIER_ATTACHMENT_TOTAL_BYTES
)
return 'O conjunto de anexos deve ter até 50 MB.';
return null;
}
export async function generateProjectDossierDocx(
...args: Parameters<
typeof import('./project-dossier-document.ts').generateProjectDossierDocx
>
): ReturnType<
typeof import('./project-dossier-document.ts').generateProjectDossierDocx
> {
const document = await import('./project-dossier-document.ts');
return document.generateProjectDossierDocx(...args);
}
export async function createProjectDossierPrintHtml(
...args: Parameters<
typeof import('./project-dossier-document.ts').createProjectDossierPrintHtml
>
): ReturnType<
typeof import('./project-dossier-document.ts').createProjectDossierPrintHtml
> {
const document = await import('./project-dossier-document.ts');
return document.createProjectDossierPrintHtml(...args);
}
export async function generateProjectDossierPackage(
...args: Parameters<
typeof import('./project-dossier-document.ts').generateProjectDossierPackage
>
): ReturnType<
typeof import('./project-dossier-document.ts').generateProjectDossierPackage
> {
const document = await import('./project-dossier-document.ts');
return document.generateProjectDossierPackage(...args);
}