180 lines
16 KiB
TypeScript
180 lines
16 KiB
TypeScript
import { csvCellWithDelimiter } from './csv.js';
|
||
import {
|
||
calculateFinancialTools,
|
||
restoreFinancialToolsSnapshot,
|
||
} from './financial-calculators.ts';
|
||
import {
|
||
forecastBusinessPlan,
|
||
restoreBusinessPlanSnapshot,
|
||
} from './business-financial-plan.ts';
|
||
import { getProjectReadiness, readPlannedRiskResponses } from './project-readiness.ts';
|
||
|
||
export type TeamNeed = {
|
||
competency: string;
|
||
state: 'available' | 'gap' | 'unknown';
|
||
action: string;
|
||
estimated_cost: string;
|
||
due_date: string;
|
||
};
|
||
|
||
export type ProjectMilestone = {
|
||
title: string;
|
||
due_date: string;
|
||
status: 'planned' | 'in_progress' | 'completed';
|
||
planned_amount: string;
|
||
actual_amount: string;
|
||
};
|
||
|
||
export function readRows<T>(value: string, allowed: readonly string[]): T[] {
|
||
try {
|
||
const parsed: unknown = JSON.parse(value || '[]');
|
||
if (!Array.isArray(parsed)) return [];
|
||
return parsed.filter((row): row is T =>
|
||
Boolean(row && typeof row === 'object' && !Array.isArray(row) &&
|
||
allowed.every((key) => typeof (row as Record<string, unknown>)[key] === 'string')),
|
||
);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export type ProjectDossierRow = [section: string, field: string, value: string, origin: string];
|
||
|
||
export function buildProjectDossierRows(draft: Record<string, string | string[]>): ProjectDossierRow[] {
|
||
const rows: ProjectDossierRow[] = [
|
||
['Secção', 'Campo', 'Valor', 'Origem / estado'],
|
||
['Projecto', 'Título', String(draft.project_title ?? ''), 'Declarado pelo utilizador'],
|
||
['Projecto', 'Versão do questionário', String(draft.project_questionnaire_version ?? 'legacy-unversioned'), 'Metadado do questionário; não altera resultados guardados'],
|
||
['Projecto', 'Resumo', String(draft.project_summary ?? ''), 'Declarado pelo utilizador'],
|
||
['Projecto', 'Problema', String(draft.project_problem ?? ''), 'Declarado pelo utilizador'],
|
||
['Projecto', 'Clientes/beneficiários', String(draft.target_customers ?? ''), 'Declarado pelo utilizador'],
|
||
['Projecto', 'Proposta de valor', String(draft.value_proposition ?? ''), 'Declarado pelo utilizador'],
|
||
['Projecto', 'Tipo de projecto', String(draft.project_type_label ?? draft.project_type ?? ''), 'Declarado/seleccionado pelo utilizador'],
|
||
['Projecto', 'Sector', String(draft.sector_id_label ?? draft.sector_id ?? ''), 'Selecção declarada pelo utilizador'],
|
||
['Projecto', 'Actividade económica', String(draft.activity_id_label ?? draft.activity_id ?? ''), 'Selecção declarada pelo utilizador'],
|
||
['Projecto', 'Produtos e serviços', String(draft.products_services ?? ''), 'Declarado pelo utilizador'],
|
||
['Projecto', 'Fase de maturidade', String(draft.maturity_stage_label ?? draft.maturity_stage ?? ''), 'Declarada pelo utilizador'],
|
||
['Projecto', 'Data prevista de início', String(draft.expected_start_date ?? ''), 'Declarada pelo utilizador; data não verificada'],
|
||
['Projecto', 'Duração prevista', draft.project_duration_years ? `${draft.project_duration_years} anos` : '', 'Declarada pelo utilizador'],
|
||
['Mercado', 'Hipótese', String(draft.market_assumption ?? ''), 'Informação declarada, não verificada'],
|
||
['Mercado', 'Fonte e data', String(draft.market_source ?? ''), 'Opcional; não verificada'],
|
||
['Mercado', 'Concorrência e alternativas', String(draft.competitor_assumption ?? ''), 'Hipótese declarada; não verificada'],
|
||
['Mercado', 'Canal de venda', String(draft.sales_channel_assumption ?? ''), 'Planeamento declarado; não confirmado'],
|
||
['Mercado', 'Acções de divulgação', String(draft.marketing_approach ?? ''), 'Planeamento declarado; não confirmado'],
|
||
['Localização', 'Província', String(draft.province_id_label ?? ''), 'Selecção de catálogo territorial'],
|
||
['Localização', 'Distrito', String(draft.district_id_label ?? ''), 'Selecção de catálogo territorial'],
|
||
['Localização', 'Posto administrativo', String(draft.admin_post_id_label ?? ''), 'Selecção de catálogo territorial'],
|
||
['Localização', 'Localidade', String(draft.locality_id_label ?? ''), 'Selecção de catálogo territorial'],
|
||
['Localização', 'Critérios e pontos a confirmar', String(draft.location_rationale ?? ''), 'Declaração do utilizador; não confirma disponibilidade'],
|
||
['Plano financeiro', 'Estado do plano detalhado', draft.business_plan_snapshot ? 'Snapshot versionado disponível; usar também a exportação do plano financeiro' : 'Sem plano empresarial detalhado preenchido', 'Pressupostos declarados; não é demonstração contabilística'],
|
||
['Equipa', 'Nota anterior sobre competências', String(draft.skills_needed ?? ''), 'Texto declarado existente'],
|
||
['Execução', 'Nota anterior sobre marcos', String(draft.project_milestones ?? ''), 'Texto declarado existente'],
|
||
['Financiamento', 'Estado de pré-elegibilidade', 'Informação insuficiente', 'Sem catálogo documentado de produtos e critérios'],
|
||
['Necessidade de negócio', 'Descrição', String(draft.opportunity_description ?? ''), 'Declarado pelo utilizador'],
|
||
['Necessidade de negócio', 'Infra-estrutura necessária', (Array.isArray(draft.infrastructure_needs) ? draft.infrastructure_needs : []).join(', '), 'Declarado/seleccionado pelo utilizador'],
|
||
['Financiamento', 'Montante pretendido', String(draft.opportunity_value ?? ''), 'Declarado pelo utilizador; ver moeda separada'],
|
||
['Financiamento', 'Moeda do montante', String(draft.opportunity_currency_label ?? draft.opportunity_currency ?? ''), 'Declarada/seleccionada; sem avaliação de produto'],
|
||
];
|
||
const plannedResponses = readPlannedRiskResponses(draft);
|
||
for (const item of getProjectReadiness(draft)) {
|
||
const response = plannedResponses.find((entry) => entry.risk_id === item.id);
|
||
const responseText = response
|
||
? [response.action, response.owner ? `Responsável: ${response.owner}` : '', response.estimated_cost ? `Custo previsto: ${response.estimated_cost} ${String(draft.currency ?? '')}` : '', response.due_date ? `Prazo: ${response.due_date}` : ''].filter(Boolean).join(' · ')
|
||
: 'Resposta ainda não registada';
|
||
rows.push(['Risco', item.title, `${item.origin} ${item.affected} Consequência possível: ${item.consequence} Acção: ${item.nextAction} Resposta planeada: ${responseText}`, 'Derivado das respostas e campos em falta; estimativa qualitativa, sem probabilidade ou gravidade atribuída']);
|
||
}
|
||
const finance = restoreFinancialToolsSnapshot(String(draft.financial_tools_snapshot ?? ''));
|
||
if (finance) {
|
||
const calculation = calculateFinancialTools({
|
||
loan: {
|
||
principal: finance.loan.principal,
|
||
annualRatePercent: finance.loan.rate,
|
||
rateType: finance.loan.rateType,
|
||
termMonths: Number(finance.loan.months),
|
||
periodMonths: Number(finance.loan.periodMonths),
|
||
gracePeriods: Number(finance.loan.gracePeriods),
|
||
upfrontFee: finance.loan.upfrontFee,
|
||
recurringFee: finance.loan.recurringFee,
|
||
},
|
||
breakEven: {
|
||
fixedCosts: finance.breakEven.fixed,
|
||
unitPrice: finance.breakEven.price,
|
||
variableCostPerUnit: finance.breakEven.variable,
|
||
},
|
||
funding: {
|
||
totalInvestment: finance.funding.total,
|
||
securedFunding: finance.funding.secured,
|
||
},
|
||
incomes: finance.incomes.map((row) => row.amount),
|
||
expenses: finance.expenses.map((row) => row.amount),
|
||
});
|
||
const currency = String(draft.currency ?? '') || 'moeda não indicada';
|
||
const source = 'Estimativa matemática baseada em pressupostos declarados; não é oferta de crédito nem resultado oficial';
|
||
rows.push(
|
||
['Plano financeiro', 'Periodicidade do orçamento', finance.budgetPeriod, 'Declarada pelo utilizador'],
|
||
['Empréstimo educativo', 'Capital pressuposto', `${finance.loan.principal} ${currency}`, 'Declarado pelo utilizador'],
|
||
['Empréstimo educativo', 'Taxa anual pressuposta', `${finance.loan.rate}% (${finance.loan.rateType})`, 'Declarada pelo utilizador; não é taxa de produto'],
|
||
['Empréstimo educativo', 'Prazo pressuposto', `${finance.loan.months} meses`, 'Declarado pelo utilizador'],
|
||
['Empréstimo educativo', 'Método e pressupostos substituídos', `Saldo decrescente; capital ${finance.loan.principal}; taxa ${finance.loan.rate}% ${finance.loan.rateType}; ${finance.loan.months} meses; período ${finance.loan.periodMonths} meses; carência ${finance.loan.gracePeriods}; comissão inicial ${finance.loan.upfrontFee}; comissão periódica ${finance.loan.recurringFee}`, 'Cronograma do simulador; taxa declarada, sem correspondência a produto'],
|
||
['Empréstimo educativo', 'Prestação do primeiro período', calculation.loan.value?.schedule[0]?.totalPayment ? `${calculation.loan.value.schedule[0].totalPayment} ${currency}` : 'Indeterminado', source],
|
||
['Empréstimo educativo', 'Juros totais estimados', calculation.loan.value?.totalInterest ? `${calculation.loan.value.totalInterest} ${currency}` : 'Indeterminado', source],
|
||
['Empréstimo educativo', 'Estado/erro do cálculo', calculation.loan.error ?? (calculation.loan.value ? 'Calculado pelo cronograma do simulador' : 'Indeterminado'), source],
|
||
['Ponto de equilíbrio', 'Fórmula e valores substituídos', `${finance.breakEven.fixed} / (${finance.breakEven.price} − ${finance.breakEven.variable}); unidades arredondadas para cima`, 'Custos fixos ÷ margem de contribuição unitária; pressupostos declarados'],
|
||
['Ponto de equilíbrio', 'Unidades estimadas', calculation.breakEven.value?.breakEvenUnits ?? 'Indeterminado', source],
|
||
['Ponto de equilíbrio', 'Vendas correspondentes', calculation.breakEven.value?.breakEvenRevenue ? `${calculation.breakEven.value.breakEvenRevenue} ${currency}` : 'Indeterminado', source],
|
||
['Ponto de equilíbrio', 'Estado/erro do cálculo', calculation.breakEven.error ?? (calculation.breakEven.value ? 'Calculado pelo simulador' : 'Indeterminado'), source],
|
||
['Financiamento', 'Investimento total pressuposto', `${finance.funding.total} ${currency}`, 'Declarado pelo utilizador'],
|
||
['Financiamento', 'Montante já mobilizado pressuposto', `${finance.funding.secured} ${currency}`, 'Declarado pelo utilizador'],
|
||
['Financiamento', 'Fórmula e valores substituídos', `${finance.funding.total} − ${finance.funding.secured}`, 'Investimento total declarado − financiamento mobilizado declarado'],
|
||
['Financiamento', 'Diferença por financiar', calculation.funding.value?.gap ? `${calculation.funding.value.gap} ${currency}` : calculation.funding.value?.gap === '0.00' ? `0.00 ${currency}` : 'Indeterminado', source],
|
||
['Financiamento', 'Estado/erro do cálculo', calculation.funding.error ?? (calculation.funding.value ? 'Calculado pelo simulador' : 'Indeterminado'), source],
|
||
['Orçamento', 'Fórmula e valores substituídos', `${calculation.budget.value?.incomeTotal ?? 'Não informado'} − ${calculation.budget.value?.expenseTotal ?? 'Não informado'}`, 'Receitas declaradas − despesas declaradas; valores ausentes não são zero'],
|
||
['Orçamento', 'Receitas declaradas', calculation.budget.value?.incomeTotal ? `${calculation.budget.value.incomeTotal} ${currency}` : 'Indeterminado', 'Declarado pelo utilizador'],
|
||
['Orçamento', 'Despesas declaradas', calculation.budget.value?.expenseTotal ? `${calculation.budget.value.expenseTotal} ${currency}` : 'Indeterminado', 'Declarado pelo utilizador'],
|
||
['Orçamento', 'Saldo operacional estimado', calculation.budget.value?.operatingBalance ? `${calculation.budget.value.operatingBalance} ${currency}` : 'Indeterminado', source],
|
||
['Orçamento', 'Estado/erro do cálculo', calculation.budget.error ?? (calculation.budget.value ? 'Calculado pelo simulador' : 'Indeterminado'), source],
|
||
);
|
||
for (const income of finance.incomes) {
|
||
rows.push(['Orçamento', `Receita declarada: ${income.label}`, `${income.amount} ${currency}`, 'Declarado pelo utilizador']);
|
||
}
|
||
for (const expense of finance.expenses) {
|
||
rows.push(['Orçamento', `Despesa declarada: ${expense.label}`, `${expense.amount} ${currency}`, 'Declarado pelo utilizador']);
|
||
}
|
||
}
|
||
const businessPlan = restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot ?? ''));
|
||
if (businessPlan) {
|
||
try {
|
||
const forecast = forecastBusinessPlan(businessPlan);
|
||
const source = 'Estimativa calculada pelo plano financeiro a partir de pressupostos declarados; não é demonstração contabilística nem resultado confirmado';
|
||
rows.push(
|
||
['Plano financeiro', 'Moeda do plano', businessPlan.currency, 'Declarada pelo utilizador'],
|
||
['Plano financeiro', 'Horizonte', `${businessPlan.periods ?? 12} meses`, 'Pressuposto declarado'],
|
||
['Plano financeiro', 'Facturação no horizonte', `${forecast.totalRevenue} ${businessPlan.currency}`, source],
|
||
['Plano financeiro', 'Fórmula e valores substituídos', `Facturação: quantidade mensal × preço unitário por produto; caixa final: saldo de abertura + cobranças e fontes − pagamentos. Horizonte: ${businessPlan.periods ?? 12} meses; moeda: ${businessPlan.currency}.`, 'Métodos do plano financeiro; agendas e pressupostos declarados no snapshot'],
|
||
['Plano financeiro', 'Cobranças no horizonte', `${forecast.totalCashCollections} ${businessPlan.currency}`, source],
|
||
['Plano financeiro', 'Pagamentos no horizonte', `${forecast.totalPayments} ${businessPlan.currency}`, source],
|
||
['Plano financeiro', 'Resultado antes de impostos', `${forecast.totalProfitBeforeTax} ${businessPlan.currency}`, source],
|
||
['Plano financeiro', 'Caixa final projectada', `${forecast.endingCash} ${businessPlan.currency}`, source],
|
||
['Plano financeiro', 'Reconciliação do financiamento', forecast.fundingStatus, 'Comparação calculada entre usos e fontes declarados'],
|
||
['Plano financeiro', 'Diferença entre usos e fontes', `${forecast.fundingGap} ${businessPlan.currency}`, source],
|
||
['Plano financeiro', 'Taxa de câmbio de apresentação', businessPlan.fxRate ? `${businessPlan.fxRate}; ${businessPlan.fxDate ?? 'data não informada'}; ${businessPlan.fxSource ?? 'fonte não informada'}` : 'Não utilizada', businessPlan.fxRate ? 'Pressuposto de planeamento declarado pelo utilizador; não verificado' : 'Sem conversão'],
|
||
);
|
||
} catch {
|
||
rows.push(['Plano financeiro', 'Estado do cálculo', 'Indeterminado', 'Snapshot inválido ou pressupostos incompatíveis; reveja o plano antes de exportar']);
|
||
}
|
||
}
|
||
for (const [index, need] of readRows<TeamNeed>(String(draft.project_team_snapshot ?? ''), ['competency', 'state', 'action', 'estimated_cost', 'due_date']).entries()) {
|
||
rows.push(['Equipa', `Necessidade ${index + 1}`, `${need.competency}; ${need.state}; resposta: ${need.action}; custo: ${need.estimated_cost}; prazo: ${need.due_date}`, 'Declarado pelo utilizador; sem certificação ou correspondência']);
|
||
}
|
||
for (const [index, milestone] of readRows<ProjectMilestone>(String(draft.project_execution_snapshot ?? ''), ['title', 'due_date', 'status', 'planned_amount', 'actual_amount']).entries()) {
|
||
rows.push(['Execução', `Marco ${index + 1}`, `${milestone.title}; ${milestone.status}; prazo: ${milestone.due_date}; planeado: ${milestone.planned_amount}; realizado: ${milestone.actual_amount}`, 'Planeado/realizado declarado pelo utilizador']);
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
export function exportProjectDossierCsv(draft: Record<string, string | string[]>): string {
|
||
const rows = buildProjectDossierRows(draft);
|
||
// Semicolon is the active list separator in the target Excel locale (pt-MZ).
|
||
return `\uFEFF${rows.map((row) => row.map((value) => csvCellWithDelimiter(value, ';')).join(';')).join('\r\n')}`;
|
||
}
|