import { displayValue } from './display-value.ts'; import { fiscalQaRows } from './fiscal-qa-preview.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 | 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, 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 = { 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 = { Projecto: 'project', Mercado: 'market', Localização: 'location', 'Plano financeiro': 'finance', 'Empréstimo educativo': 'finance', 'Ponto de equilíbrio': 'finance', Orçamento: 'finance', Tesouraria: 'finance', 'IVA mensal': 'finance', Equipa: 'team', 'Necessidade de negócio': 'resources', Financiamento: 'resources', Risco: 'risk', Execução: 'execution', }; function makeSections( draft: Record, rows: ProjectDossierRow[], selectedSections: ReadonlySet, references: DossierReference[], currentCalculation: DossierCurrentCalculation | null, generatedAt: string, version: string, ): DossierReportSection[] { const sections = new Map(); 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', }, ); const highlights = new Set([ 'Saldo operacional estimado', 'Diferença por financiar', 'Prestação do primeiro período', 'Saldo final total', 'Caixa conhecida antes de imposto em falta', ]); const budgetPeriod = rows.find( (row) => row[1] === 'Periodicidade do orçamento', )?.[2]; for (const [, field, value, origin] of rows.slice(1)) { if (!highlights.has(field)) continue; summary.rows.push({ field: field === 'Saldo operacional estimado' && budgetPeriod ? `${field} — ${budgetPeriod}` : field === 'Saldo final total' ? 'Saldo final de tesouraria — 12 meses' : field, value: absent( formatDossierReportValue( field, value, text(draft, 'currency'), ), ), origin, classification: classification(origin), }); } } if (draft.context_applicability_snapshot) { try { const context = JSON.parse( String(draft.context_applicability_snapshot), ); if (context.version === 1) { const section = sections.get('sources'); const zone = context.zone; section?.rows.push({ field: 'Contexto do investimento', value: `Revisão ${context.context_revision}; ${zone ? `${zone.title}, versão ${zone.version}` : 'sem ZEE, ZFI ou parque seleccionado'}`, origin: 'Referência do mesmo dossier no momento da gravação', classification: 'Contexto versionado; não é reconhecimento jurídico', }); section?.paragraphs.push( zone ? 'Benefício fiscal da zona indeterminado: falta regra executável ligada às condições e reconhecimento. O IVA apresentado é apenas cenário geral sem benefício; não representa o regime especial nem o apuramento final.' : 'Cenário geral de preparação; sem confirmação automática de elegibilidade fiscal.', ); const preparation = context.preparation; if (preparation?.enabled) { const modes: Record = { authorization: 'Pedido de autorização', registration: 'Pedido de mero registo', indeterminate: 'Enquadramento por determinar', }; section?.rows.push({ field: 'Preparação do investimento privado', value: modes[preparation.mode] ?? 'Por determinar', origin: `Contexto congelado v${context.context_revision}; ${preparation.version}`, classification: 'Declarações e requisitos de preparação; sem aprovação jurídica', }); section?.paragraphs.push( preparation.next, preparation.calculation_notice, preparation.notice, ); const omitted = (preparation.requirements ?? []).filter( (requirement: { state: string }) => requirement.state === 'not_applicable', ); if (omitted.length) section?.paragraphs.push( `Não exigido neste contexto: ${omitted.map((requirement: { label: string; reason: string }) => `${requirement.label} (${requirement.reason})`).join('; ')}.`, ); for (const requirement of preparation.requirements ?? []) { if (requirement.state === 'not_applicable') continue; section?.rows.push({ field: requirement.label, value: requirement.state === 'not_applicable' ? 'Não aplicável; histórico preservado' : requirement.state === 'associated' ? `Associado: ${requirement.document?.file_name ?? 'documento do dossier'}; conteúdo por conferir` : requirement.state === 'missing' ? 'Documento por associar' : 'Informação em falta para decidir', origin: `${requirement.reason} ${requirement.source?.reference ?? ''}; ${requirement.source?.url ?? ''}`, classification: 'Preparação informativa — simulação, sem submissão à Administração', }); } } if (zone?.href) section?.rows.push({ field: 'Ficha e fundamentos da zona', value: zone.href, origin: `Versão consultada ${zone.version}`, classification: 'Referência consultável; condições por verificar', }); } } catch { sections .get('sources') ?.paragraphs.push( 'Não foi possível recuperar o contexto guardado; o enquadramento territorial permanece indeterminado.', ); } } 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 && ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes( String(currentCalculation?.snapshot?.scenario_kind), ) && currentCalculation?.snapshot ) { for (const [field, value] of fiscalQaRows( currentCalculation.snapshot, )) { finance.rows.push({ field, value, origin: 'Snapshot fiscal preservado; sem recálculo', classification: 'Cálculo exacto; ver estado da regra no snapshot; não é saldo a pagar', }); } } 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) : null; const context = snapshot && typeof snapshot.context === 'object' && snapshot.context !== null && !Array.isArray(snapshot.context) ? (snapshot.context as Record) : 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: [ 'fiscal_rule_qa_preview', 'fiscal_rule_published', ].includes(String(snapshot?.scenario_kind)) ? `Base tributável declarada × taxa cadastrada ÷ 100; cálculo decimal exacto, sem arredondamento. Ver regra e proveniência acima.` : 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: [ 'fiscal_rule_qa_preview', 'fiscal_rule_published', ].includes(String(snapshot?.scenario_kind)) ? snapshot?.scenario_kind === 'fiscal_rule_published' ? 'Versão publicada na admissão; sem validação jurídica da operação' : 'Snapshot QA de regra em rascunho; sem activação ou validação jurídica da operação' : '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, options: { selectedSections: ReadonlySet; 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, ): 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[], ): 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); }