Files
Investidor-V2/resources/js/lib/project-dossier-report.ts
T
nala 1106509116
tests / ci (push) Canceled after 0s
vrsao 2
2026-10-08 14:02:01 +02:00

1180 lines
46 KiB
TypeScript

import { displayValue } from './display-value.ts';
import {
AlignmentType,
BorderStyle,
Document,
Footer,
HeadingLevel,
ImageRun,
Packer,
Paragraph,
SimpleField,
ShadingType,
Table,
TableCell,
TableLayoutType,
TableRow,
TextRun,
WidthType,
} from 'docx';
import JSZip from 'jszip';
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;
const MAX_EMBEDDED_TEXT_BYTES = 2 * 1024 * 1024;
const MAX_EMBEDDED_CSV_ROWS = 250;
const MAX_EMBEDDED_CSV_COLUMNS = 16;
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';
}
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: 'Relógio do dispositivo no momento da exportação',
classification: 'Metadado do relatório',
},
{
field: 'Versão/revisão do dossier',
value: version,
origin: 'Revisão de rascunho guardada ou impressão digital local',
classification: 'Metadado técnico',
},
);
}
for (const [sectionName, field, value, origin] of rows.slice(1)) {
const id = rowSection[sectionName];
if (!id) continue;
const section = sections.get(id);
if (!section) continue;
section.rows.push({
field,
value: absent(value),
origin,
classification: 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: 'Metadado técnico',
},
{
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;
}
function parseDelimited(textValue: string, delimiter: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = '';
let quoted = false;
for (let index = 0; index < textValue.length; index++) {
const char = textValue[index];
if (char === '"') {
if (quoted && textValue[index + 1] === '"') {
cell += '"';
index++;
} else quoted = !quoted;
} else if (!quoted && char === delimiter) {
row.push(cell);
cell = '';
} else if (!quoted && (char === '\n' || char === '\r')) {
if (char === '\r' && textValue[index + 1] === '\n') index++;
row.push(cell);
rows.push(row);
row = [];
cell = '';
} else cell += char;
}
if (cell || row.length) {
row.push(cell);
rows.push(row);
}
const cleaned = rows.map((values) =>
values.map((value) => value.replace(/^\uFEFF/, '')),
);
return cleaned
.slice(0, MAX_EMBEDDED_CSV_ROWS)
.map((values) => values.slice(0, MAX_EMBEDDED_CSV_COLUMNS));
}
function csvDelimiter(value: string): string {
const first = value.split(/\r?\n/, 1)[0] ?? '';
const semicolons = (first.match(/;/g) ?? []).length;
const commas = (first.match(/,/g) ?? []).length;
const tabs = (first.match(/\t/g) ?? []).length;
return tabs > commas && tabs > semicolons
? '\t'
: semicolons > commas
? ';'
: ',';
}
function safePackageName(name: string, used: Set<string>): string {
const basename = name.split(/[\\/]/).pop() ?? 'anexo';
const cleaned =
basename
.normalize('NFKC')
.replace(/[^\p{L}\p{N}._-]+/gu, '_')
.replace(/^\.+/, '')
.slice(0, 120) || 'anexo';
let candidate = cleaned;
let suffix = 2;
while (used.has(candidate.toLocaleLowerCase('en'))) {
const dot = cleaned.lastIndexOf('.');
candidate =
dot > 0
? `${cleaned.slice(0, dot)}_${suffix}${cleaned.slice(dot)}`
: `${cleaned}_${suffix}`;
suffix++;
}
used.add(candidate.toLocaleLowerCase('en'));
return candidate;
}
function attachmentStatus(
attachment: DossierAttachment,
annexesIncluded: boolean,
): string {
if (!attachment.include) return 'Não seleccionado para exportação';
if (!annexesIncluded)
return 'Original no pacote ZIP; secção de anexos não foi incluída no relatório';
const kind = classifyDossierAttachment(attachment.file);
if (kind === 'image')
return `Fotografia incorporada${attachment.caption ? `; legenda: ${attachment.caption}` : ''}; original também no pacote ZIP`;
if (kind === 'csv')
return 'Tabela incorporada; original também no pacote ZIP';
if (kind === 'text')
return 'Texto incorporado; original também no pacote ZIP';
return 'Conteúdo não incorporado; original preservado no pacote ZIP';
}
function tableForDocx(headers: string[], data: string[][]): Table {
const widths =
headers.length === 4
? [1850, 1850, 3450, 2210]
: headers.length === 3
? [1900, 4400, 3060]
: headers.map(() => Math.floor(9360 / headers.length));
const tableRows = [headers, ...data].map(
(values, rowIndex) =>
new TableRow({
tableHeader: rowIndex === 0,
cantSplit: true,
children: headers.map(
(_, cellIndex) =>
new TableCell({
width: {
size: widths[cellIndex] ?? 1500,
type: WidthType.DXA,
},
shading:
rowIndex === 0
? {
type: ShadingType.CLEAR,
fill: '123B35',
}
: undefined,
margins: {
top: 80,
bottom: 80,
left: 100,
right: 100,
},
children: [
new Paragraph({
children: [
new TextRun({
text: values[cellIndex] ?? '',
bold: rowIndex === 0,
color:
rowIndex === 0
? 'FFFFFF'
: '17211F',
size: 18,
}),
],
}),
],
}),
),
}),
);
return new Table({
rows: tableRows,
width: { size: 100, type: WidthType.PERCENTAGE },
columnWidths: widths,
layout: TableLayoutType.FIXED,
borders: Object.fromEntries(
[
'top',
'bottom',
'left',
'right',
'insideHorizontal',
'insideVertical',
].map((side) => [
side,
{ style: BorderStyle.SINGLE, size: 4, color: 'CBD5D1' },
]),
) as never,
});
}
function sectionTable(section: DossierReportSection): Table | null {
if (!section.rows.length) return null;
return tableForDocx(
['Campo', 'Conteúdo', 'Classificação / origem'],
section.rows.map((row) => [
row.field,
row.value,
`${row.classification}. ${row.origin}`,
]),
);
}
async function imageRuns(
attachment: DossierAttachment,
): Promise<(Paragraph | Table)[]> {
const file = attachment.file;
const type = file.type === 'image/jpeg' ? 'jpg' : 'png';
const data = new Uint8Array(await file.arrayBuffer());
let sourceWidth = 0;
let sourceHeight = 0;
if (type === 'png') {
if (
data.length < 24 ||
data[0] !== 0x89 ||
data[1] !== 0x50 ||
data[2] !== 0x4e ||
data[3] !== 0x47 ||
data[12] !== 0x49 ||
data[13] !== 0x48 ||
data[14] !== 0x44 ||
data[15] !== 0x52
) {
throw new Error(
`A fotografia ${file.name} não tem um cabeçalho PNG válido.`,
);
}
sourceWidth = new DataView(
data.buffer,
data.byteOffset,
data.byteLength,
).getUint32(16);
sourceHeight = new DataView(
data.buffer,
data.byteOffset,
data.byteLength,
).getUint32(20);
} else {
if (data.length < 4 || data[0] !== 0xff || data[1] !== 0xd8)
throw new Error(
`A fotografia ${file.name} não tem um cabeçalho JPEG válido.`,
);
const frameMarkers = new Set([
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd,
0xce, 0xcf,
]);
for (let offset = 2; offset + 8 < data.length;) {
if (data[offset] !== 0xff) {
offset++;
continue;
}
while (data[offset] === 0xff) offset++;
const marker = data[offset++];
if (marker === 0xd9 || marker === 0xda) break;
if (
[0x01, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7].includes(
marker,
)
)
continue;
const segmentLength = (data[offset] << 8) | data[offset + 1];
if (segmentLength < 2 || offset + segmentLength > data.length)
break;
if (frameMarkers.has(marker)) {
sourceHeight = (data[offset + 3] << 8) | data[offset + 4];
sourceWidth = (data[offset + 5] << 8) | data[offset + 6];
break;
}
offset += segmentLength;
}
}
if (
!sourceWidth ||
!sourceHeight ||
sourceWidth * sourceHeight > 40_000_000 ||
sourceWidth > 12_000 ||
sourceHeight > 12_000
) {
throw new Error(
`As dimensões da fotografia ${file.name} são inválidas ou excedem o limite de segurança.`,
);
}
const scale = Math.min(1, 520 / sourceWidth, 620 / sourceHeight);
const width = Math.max(1, Math.round(sourceWidth * scale));
const height = Math.max(1, Math.round(sourceHeight * scale));
return [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new ImageRun({
data,
type,
transformation: { width, height },
altText: {
name: file.name,
description: attachment.caption || file.name,
title: file.name,
},
}),
],
}),
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({
text: attachment.caption || file.name,
italics: true,
size: 18,
}),
],
}),
];
}
async function annexDocxBlocks(
attachments: DossierAttachment[],
): Promise<(Paragraph | Table)[]> {
const blocks: (Paragraph | Table)[] = [];
for (const attachment of attachments.filter((item) => item.include)) {
const kind = classifyDossierAttachment(attachment.file);
blocks.push(
new Paragraph({
text: attachment.file.name,
heading: HeadingLevel.HEADING_3,
keepNext: true,
}),
);
if (kind === 'image') {
blocks.push(...(await imageRuns(attachment)));
} else if (kind === 'text') {
const contents = (await attachment.file.text()).replace(
/^\uFEFF/,
'',
);
for (const line of contents.split(/\r?\n/).slice(0, 1000))
blocks.push(
new Paragraph({
children: [new TextRun({ text: line || ' ' })],
}),
);
} else if (kind === 'csv') {
const contents = (await attachment.file.text()).replace(
/^\uFEFF/,
'',
);
const rows = parseDelimited(contents, csvDelimiter(contents));
const headers = rows[0] ?? [];
const data = rows.slice(1);
if (headers.length) blocks.push(tableForDocx(headers, data));
if (contents.split(/\r?\n/).length > MAX_EMBEDDED_CSV_ROWS)
blocks.push(
new Paragraph(
'A tabela foi truncada para 250 linhas no relatório; o original completo acompanha o pacote ZIP.',
),
);
} else {
blocks.push(
new Paragraph(
'Este formato não foi incorporado ao DOCX. Para preservar o original, exporte também o pacote ZIP de anexos com o respectivo índice.',
),
);
}
}
return blocks;
}
export async function generateProjectDossierDocx(
model: DossierReportModel,
attachments: DossierAttachment[],
annexesIncluded: boolean,
): Promise<Blob> {
const children: (Paragraph | Table)[] = [
new Paragraph({ text: 'InvestMoz', heading: HeadingLevel.HEADING_2 }),
new Paragraph({
text: 'Relatório consolidado do projecto',
heading: HeadingLevel.TITLE,
}),
new Paragraph({
children: [
new TextRun({ text: model.title, bold: true, size: 30 }),
],
}),
new Paragraph({
children: [
new TextRun({
text: `Gerado em ${new Date(model.generatedAt).toLocaleString('pt-MZ')} · versão ${model.version}`,
color: '52615D',
size: 20,
}),
],
}),
new Paragraph({
children: [
new TextRun({
text: 'Documento privado gerado neste dispositivo. A exportação não publica nem partilha o conteúdo.',
bold: true,
color: '8A4B08',
}),
],
}),
];
for (const section of model.sections) {
if (section.id === 'annexes') {
children.push(
new Paragraph({
text: section.title,
heading: HeadingLevel.HEADING_1,
}),
);
if (annexesIncluded)
children.push(...(await annexDocxBlocks(attachments)));
if (!attachments.some((item) => item.include))
children.push(new Paragraph('Nenhum anexo foi seleccionado.'));
if (
attachments.some(
(item) =>
item.include &&
classifyDossierAttachment(item.file) ===
'original-only',
)
)
children.push(
new Paragraph(
'Os ficheiros que não podem ser incorporados de forma legível devem ser exportados também no pacote ZIP, com índice.',
),
);
continue;
}
children.push(
new Paragraph({
text: section.title,
heading: HeadingLevel.HEADING_1,
keepNext: true,
}),
);
for (const paragraph of section.paragraphs)
children.push(
new Paragraph({ children: [new TextRun({ text: paragraph })] }),
);
const table = sectionTable(section);
if (table) children.push(table);
if (!section.rows.length && !section.paragraphs.length)
children.push(
new Paragraph('Não há informação fornecida nesta secção.'),
);
}
const footer = new Footer({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
children: [
new TextRun({
text: 'InvestMoz · relatório privado · página ',
}),
new SimpleField('PAGE'),
],
}),
],
});
const doc = new Document({
creator: 'InvestMoz',
title: `Relatório do projecto — ${model.title}`,
description:
'Dossier consolidado exportado localmente a partir do formulário InvestMoz.',
styles: {
default: {
document: {
run: { font: 'Aptos', size: 20, color: '17211F' },
paragraph: { spacing: { after: 120, line: 276 } },
},
},
paragraphStyles: [
{
id: 'Title',
name: 'Title',
basedOn: 'Normal',
run: {
font: 'Aptos Display',
size: 34,
bold: true,
color: '123B35',
},
paragraph: { spacing: { before: 200, after: 220 } },
},
{
id: 'Heading1',
name: 'Heading 1',
basedOn: 'Normal',
next: 'Normal',
run: {
font: 'Aptos Display',
size: 26,
bold: true,
color: '123B35',
},
paragraph: {
spacing: { before: 320, after: 150 },
keepNext: true,
},
},
{
id: 'Heading2',
name: 'Heading 2',
basedOn: 'Normal',
next: 'Normal',
run: {
font: 'Aptos',
size: 24,
bold: true,
color: '27675C',
},
paragraph: {
spacing: { before: 260, after: 120 },
keepNext: true,
},
},
{
id: 'Heading3',
name: 'Heading 3',
basedOn: 'Normal',
next: 'Normal',
run: {
font: 'Aptos',
size: 22,
bold: true,
color: '27675C',
},
paragraph: {
spacing: { before: 200, after: 100 },
keepNext: true,
},
},
],
},
sections: [
{
properties: {
page: {
size: { width: 12240, height: 15840 },
margin: {
top: 900,
right: 960,
bottom: 900,
left: 960,
footer: 450,
header: 450,
},
},
},
footers: { default: footer },
children,
},
],
});
return Packer.toBlob(doc);
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
async function fileDataUrl(file: File): Promise<string> {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return `data:${file.type};base64,${btoa(binary)}`;
}
function htmlTable(headers: string[], rows: string[][]): string {
return `<table><thead><tr>${headers.map((value) => `<th>${escapeHtml(value)}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${headers.map((_, index) => `<td>${escapeHtml(row[index] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
}
export async function createProjectDossierPrintHtml(
model: DossierReportModel,
attachments: DossierAttachment[],
annexesIncluded: boolean,
): Promise<string> {
const content: string[] = [];
for (const section of model.sections) {
content.push(`<section><h2>${escapeHtml(section.title)}</h2>`);
for (const paragraph of section.paragraphs)
content.push(`<p>${escapeHtml(paragraph)}</p>`);
if (section.rows.length)
content.push(
htmlTable(
['Campo', 'Conteúdo', 'Classificação / origem'],
section.rows.map((row) => [
row.field,
row.value,
`${row.classification}. ${row.origin}`,
]),
),
);
if (section.id === 'annexes') {
const selected = attachments.filter((item) => item.include);
if (!selected.length)
content.push('<p>Nenhum anexo foi seleccionado.</p>');
if (!annexesIncluded)
content.push(
'<p>A secção de anexos foi omitida do relatório. Os originais seleccionados continuam no pacote ZIP.</p>',
);
else
for (const attachment of selected) {
const kind = classifyDossierAttachment(attachment.file);
content.push(
`<h3>${escapeHtml(attachment.file.name)}</h3>`,
);
if (kind === 'image')
content.push(
`<figure><img alt="${escapeHtml(attachment.caption || attachment.file.name)}" src="${await fileDataUrl(attachment.file)}"><figcaption>${escapeHtml(attachment.caption || attachment.file.name)}</figcaption></figure>`,
);
else if (kind === 'text')
content.push(
`<pre>${escapeHtml((await attachment.file.text()).slice(0, MAX_EMBEDDED_TEXT_BYTES))}</pre>`,
);
else if (kind === 'csv') {
const source = (await attachment.file.text()).slice(
0,
MAX_EMBEDDED_TEXT_BYTES,
);
const rows = parseDelimited(
source,
csvDelimiter(source),
);
content.push(htmlTable(rows[0] ?? [], rows.slice(1)));
} else
content.push(
'<p>Conteúdo original não incorporado nesta versão PDF. Para obter o original, exporte também o pacote ZIP com índice.</p>',
);
}
}
if (
!section.rows.length &&
!section.paragraphs.length &&
section.id !== 'annexes'
)
content.push('<p>Não há informação fornecida nesta secção.</p>');
content.push('</section>');
}
return `<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><title>Relatório — ${escapeHtml(model.title)}</title><style>
@page{size:A4;margin:18mm 17mm 20mm}*{box-sizing:border-box}body{font:10.5pt/1.45 Arial,sans-serif;color:#17211f;margin:0}h1{font-size:24pt;color:#123b35;margin:0 0 7mm}h2{font-size:16pt;color:#123b35;border-bottom:1px solid #b8c9c3;padding-bottom:2mm;margin:8mm 0 3mm;break-after:avoid}h3{font-size:12pt;color:#27675c;break-after:avoid}p{white-space:pre-wrap}table{width:100%;border-collapse:collapse;table-layout:fixed;margin:3mm 0 5mm;break-inside:auto}th,td{border:1px solid #cbd5d1;padding:5px 6px;text-align:left;vertical-align:top;overflow-wrap:anywhere}th{background:#123b35;color:white}tr{break-inside:avoid}th:first-child,td:first-child{width:22%}th:last-child,td:last-child{width:32%;font-size:9pt;color:#40524d}.private{padding:3mm;background:#fff4df;border-left:3px solid #b66a0c;font-weight:bold}section{break-inside:auto}figure{margin:5mm auto;text-align:center;break-inside:avoid}img{max-width:100%;max-height:210mm;object-fit:contain}figcaption{font-style:italic;color:#52615d}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:9.5pt/1.4 Arial,sans-serif}@media print{a{color:inherit;text-decoration:none}.no-print{display:none}}
</style></head><body><h1>InvestMoz<br>Relatório consolidado do projecto</h1><p><strong>${escapeHtml(model.title)}</strong><br>Gerado em ${escapeHtml(new Date(model.generatedAt).toLocaleString('pt-MZ'))}<br>Versão/revisão: ${escapeHtml(model.version)}</p><p class="private">Documento privado. O portal não publica nem partilha este conteúdo.</p>${content.join('')}<footer><small>InvestMoz · Relatório privado · ${escapeHtml(model.version)}</small></footer><script>window.addEventListener('load',()=>setTimeout(()=>window.print(),350));</script></body></html>`;
}
export async function generateProjectDossierPackage(
docxBlob: Blob,
attachments: DossierAttachment[],
annexesIncluded: boolean,
): Promise<Blob> {
const zip = new JSZip();
zip.file('Relatorio/relatorio-projecto-investmoz.docx', docxBlob);
const usedNames = new Set<string>();
const selected = attachments.filter((item) => item.include);
const index = [
'ÍNDICE DO PACOTE DE RELATÓRIO INVESTMOZ',
'',
'O relatório e os anexos são privados e foram seleccionados localmente pelo utilizador.',
'',
].join('\n');
const records: string[] = [];
for (const attachment of selected) {
const filename = safePackageName(attachment.file.name, usedNames);
zip.file(`Anexos/${filename}`, attachment.file, { binary: true });
records.push(
`Ficheiro: ${filename}\nTamanho: ${attachment.file.size} bytes\nTipo: ${attachment.file.type || 'desconhecido'}\nSituação: ${attachmentStatus(attachment, annexesIncluded)}\nLegenda: ${attachment.caption || 'Não indicada'}\n`,
);
}
zip.file(
'indice-anexos.txt',
`${index}${records.length ? records.join('\n') : 'Não foram seleccionados anexos.\n'}`,
);
return zip.generateAsync({
type: 'blob',
mimeType: 'application/zip',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
}