3136 lines
130 KiB
TypeScript
3136 lines
130 KiB
TypeScript
import { csvCell } from './csv.js';
|
||
|
||
type ProductLine = {
|
||
label: string;
|
||
monthlyUnits: string;
|
||
/** Optional monthly unit overrides; omitted keeps monthlyUnits constant. */
|
||
monthlyUnitsByPeriod?: string[];
|
||
unitPrice: string;
|
||
unitPriceByPeriod?: string[];
|
||
unitVariableCost: string;
|
||
unitVariableCostByPeriod?: string[];
|
||
productInventoryOpening?: string;
|
||
productInventoryPurchasesByPeriod?: InventoryPurchasePeriod[] | null;
|
||
};
|
||
export const MAX_BUSINESS_PLAN_SNAPSHOT_LENGTH = 60_000;
|
||
export type ScenarioAdjustment = {
|
||
unitVolumePercent: string;
|
||
unitPricePercent: string;
|
||
unitCostPercent: string;
|
||
operatingCostsPercent: string;
|
||
cashCollectionsPercent: string;
|
||
};
|
||
export type OpeningReceivableInstallment = { month: number; percent: string };
|
||
export type OpeningPayableInstallment = { month: number; percent: string };
|
||
export type SalesReceivableRecovery = { month: number; percent: string };
|
||
export type InventoryPurchasePeriod = {
|
||
amount: string;
|
||
leadMonths: number;
|
||
paymentLagMonths?: number;
|
||
paymentInstallments?: SupplierPaymentInstallment[];
|
||
};
|
||
export type SupplierPaymentInstallment = {
|
||
lagMonths: number;
|
||
percent: string;
|
||
};
|
||
|
||
function validCurrency(value: string): boolean {
|
||
const intl = Intl as unknown as {
|
||
supportedValuesOf?: (key: 'currency') => string[];
|
||
};
|
||
const options = new Set([
|
||
'MZN',
|
||
'USD',
|
||
'EUR',
|
||
'ZAR',
|
||
...(intl.supportedValuesOf?.('currency') ?? []),
|
||
]);
|
||
try {
|
||
return (
|
||
options.has(value) &&
|
||
new Intl.NumberFormat('en', {
|
||
style: 'currency',
|
||
currency: value,
|
||
}).resolvedOptions().maximumFractionDigits === 2
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export type BusinessFinancialPlanInput = {
|
||
currency: string;
|
||
presentationCurrency?: string;
|
||
fxRate?: string;
|
||
fxDate?: string;
|
||
fxSource?: string;
|
||
openingCash: string;
|
||
products: ProductLine[];
|
||
monthlyInventoryPurchases?: string;
|
||
inventoryPurchaseLeadMonths?: number;
|
||
/** Optional monthly order/lead-time schedule; inactive values remain saved for later editing. */
|
||
inventoryPurchaseSchedule?: InventoryPurchasePeriod[] | null;
|
||
useInventoryPurchaseSchedule?: boolean;
|
||
useProductInventorySchedule?: boolean;
|
||
/** Pays product purchase orders by declared supplier installments instead of the manual variable-payment schedule. */
|
||
useSupplierPaymentTerms?: boolean;
|
||
monthlyCollections: string;
|
||
/** Null keeps the legacy user-entered cash collection schedule. 0–12 schedules invoiced sales. */
|
||
salesCollectionLagMonths?: number | null;
|
||
/** Fraction of each invoice collected at its scheduled due month; 0–100. */
|
||
salesCollectionPercent?: string;
|
||
/** Recovery as a percentage of the past-due balance remaining at each declared month. */
|
||
salesRecoverySchedule?: SalesReceivableRecovery[];
|
||
/** One declared month for collecting opening receivables; null means unscheduled. */
|
||
openingReceivablesCollectionMonth?: number | null;
|
||
openingReceivablesCollectionPercent?: string;
|
||
/** Additional declared installments, each as a share of the opening receivable balance. */
|
||
openingReceivablesInstallments?: OpeningReceivableInstallment[];
|
||
/** Declared cash payments for opening payables; omitted means no payment schedule is assumed. */
|
||
openingPayablesInstallments?: OpeningPayableInstallment[];
|
||
monthlyVariableCostPayments: string;
|
||
/** Optional monthly payment schedule; independent from inventory order/receipt dates. */
|
||
monthlyVariableCostPaymentsByPeriod?: string[] | null;
|
||
monthlyOperatingExpenses: string;
|
||
monthlyOperatingExpensesByPeriod?: string[];
|
||
monthlyOperatingPayments: string;
|
||
monthlyOperatingPaymentsByPeriod?: string[];
|
||
monthlyPayrollExpense: string;
|
||
monthlyPayrollExpenseByPeriod?: string[];
|
||
monthlyPayrollPayments: string;
|
||
monthlyPayrollPaymentsByPeriod?: string[];
|
||
assetInvestment: string;
|
||
inventory: string;
|
||
receivables: string;
|
||
payables: string;
|
||
equity: string;
|
||
shareholderLoans: string;
|
||
bankLoanProceeds: string;
|
||
grants: string;
|
||
assetDepreciation: string;
|
||
depreciationLifeMonths: number;
|
||
monthlyInterestExpense: string;
|
||
monthlyInterestPayment: string;
|
||
monthlyPrincipalRepayment: string;
|
||
periods?: number;
|
||
scenarioAdjustments?: {
|
||
favorable: ScenarioAdjustment;
|
||
unfavorable: ScenarioAdjustment;
|
||
};
|
||
};
|
||
|
||
export function serializeBusinessPlanSnapshot(
|
||
plan: BusinessFinancialPlanInput,
|
||
): string {
|
||
const snapshot = JSON.stringify({
|
||
version: 17,
|
||
plan: {
|
||
...plan,
|
||
...(plan.useSupplierPaymentTerms === true
|
||
? {
|
||
products: plan.products.map((product) => ({
|
||
...product,
|
||
productInventoryPurchasesByPeriod:
|
||
product.productInventoryPurchasesByPeriod?.map(
|
||
(entry) => ({
|
||
...entry,
|
||
paymentInstallments:
|
||
entry.paymentInstallments ?? [
|
||
{
|
||
lagMonths:
|
||
entry.paymentLagMonths ??
|
||
0,
|
||
percent: '100',
|
||
},
|
||
],
|
||
}),
|
||
) ?? null,
|
||
})),
|
||
}
|
||
: {}),
|
||
monthlyInventoryPurchases: plan.monthlyInventoryPurchases ?? '',
|
||
inventoryPurchaseLeadMonths: plan.inventoryPurchaseLeadMonths ?? 0,
|
||
inventoryPurchaseSchedule: plan.inventoryPurchaseSchedule ?? null,
|
||
useInventoryPurchaseSchedule:
|
||
plan.useInventoryPurchaseSchedule ?? false,
|
||
useProductInventorySchedule:
|
||
plan.useProductInventorySchedule ?? false,
|
||
useSupplierPaymentTerms: plan.useSupplierPaymentTerms ?? false,
|
||
monthlyVariableCostPaymentsByPeriod:
|
||
plan.monthlyVariableCostPaymentsByPeriod ?? null,
|
||
salesCollectionLagMonths: plan.salesCollectionLagMonths ?? null,
|
||
salesCollectionPercent: plan.salesCollectionPercent ?? '100',
|
||
openingReceivablesCollectionMonth:
|
||
plan.openingReceivablesCollectionMonth ?? null,
|
||
openingReceivablesCollectionPercent:
|
||
plan.openingReceivablesCollectionPercent ?? '100',
|
||
openingReceivablesInstallments:
|
||
plan.openingReceivablesInstallments ?? [],
|
||
openingPayablesInstallments: plan.openingPayablesInstallments ?? [],
|
||
salesRecoverySchedule: plan.salesRecoverySchedule ?? [],
|
||
},
|
||
});
|
||
if (snapshot.length > MAX_BUSINESS_PLAN_SNAPSHOT_LENGTH)
|
||
throw new Error(
|
||
'O plano financeiro excede o limite de armazenamento de 60 KB. Reduza produtos ou agendas mensais antes de guardar.',
|
||
);
|
||
return snapshot;
|
||
}
|
||
|
||
export function restoreBusinessPlanSnapshot(
|
||
snapshot: string | undefined,
|
||
): BusinessFinancialPlanInput | null {
|
||
if (!snapshot || snapshot.length > MAX_BUSINESS_PLAN_SNAPSHOT_LENGTH)
|
||
return null;
|
||
try {
|
||
const decoded = JSON.parse(snapshot) as {
|
||
version?: unknown;
|
||
plan?: unknown;
|
||
};
|
||
if (
|
||
(decoded.version !== 1 &&
|
||
decoded.version !== 2 &&
|
||
decoded.version !== 3 &&
|
||
decoded.version !== 4 &&
|
||
decoded.version !== 5 &&
|
||
decoded.version !== 6 &&
|
||
decoded.version !== 7 &&
|
||
decoded.version !== 8 &&
|
||
decoded.version !== 9 &&
|
||
decoded.version !== 10 &&
|
||
decoded.version !== 11 &&
|
||
decoded.version !== 12 &&
|
||
decoded.version !== 13 &&
|
||
decoded.version !== 14 &&
|
||
decoded.version !== 15 &&
|
||
decoded.version !== 16 &&
|
||
decoded.version !== 17) ||
|
||
!decoded.plan ||
|
||
typeof decoded.plan !== 'object'
|
||
)
|
||
return null;
|
||
const plan = decoded.plan as Record<string, unknown>;
|
||
const textFields = [
|
||
'currency',
|
||
'openingCash',
|
||
'monthlyCollections',
|
||
'monthlyVariableCostPayments',
|
||
'monthlyOperatingExpenses',
|
||
'monthlyOperatingPayments',
|
||
'monthlyPayrollExpense',
|
||
'monthlyPayrollPayments',
|
||
'assetInvestment',
|
||
'inventory',
|
||
'receivables',
|
||
'payables',
|
||
'equity',
|
||
'shareholderLoans',
|
||
'bankLoanProceeds',
|
||
'grants',
|
||
'assetDepreciation',
|
||
'monthlyInterestExpense',
|
||
'monthlyInterestPayment',
|
||
'monthlyPrincipalRepayment',
|
||
];
|
||
if (
|
||
textFields.some((field) => typeof plan[field] !== 'string') ||
|
||
!Number.isSafeInteger(plan.periods) ||
|
||
!Number.isSafeInteger(plan.depreciationLifeMonths)
|
||
)
|
||
return null;
|
||
if (
|
||
plan.monthlyInventoryPurchases !== undefined &&
|
||
typeof plan.monthlyInventoryPurchases !== 'string'
|
||
)
|
||
return null;
|
||
if (
|
||
decoded.version !== 1 &&
|
||
typeof plan.monthlyInventoryPurchases !== 'string'
|
||
)
|
||
return null;
|
||
if (
|
||
plan.inventoryPurchaseLeadMonths !== undefined &&
|
||
(!Number.isSafeInteger(plan.inventoryPurchaseLeadMonths) ||
|
||
Number(plan.inventoryPurchaseLeadMonths) < 0 ||
|
||
Number(plan.inventoryPurchaseLeadMonths) > 12)
|
||
)
|
||
return null;
|
||
if (
|
||
decoded.version === 3 &&
|
||
!Number.isSafeInteger(plan.inventoryPurchaseLeadMonths)
|
||
)
|
||
return null;
|
||
if (
|
||
[12, 13, 14, 15, 16, 17].includes(Number(decoded.version)) &&
|
||
(!Object.hasOwn(plan, 'inventoryPurchaseSchedule') ||
|
||
!Object.hasOwn(plan, 'useInventoryPurchaseSchedule') ||
|
||
typeof plan.useInventoryPurchaseSchedule !== 'boolean')
|
||
)
|
||
return null;
|
||
if (
|
||
plan.inventoryPurchaseSchedule !== undefined &&
|
||
plan.inventoryPurchaseSchedule !== null
|
||
) {
|
||
if (
|
||
![12, 13, 14, 15, 16, 17].includes(Number(decoded.version)) ||
|
||
!Array.isArray(plan.inventoryPurchaseSchedule) ||
|
||
plan.inventoryPurchaseSchedule.length < Number(plan.periods) ||
|
||
plan.inventoryPurchaseSchedule.length > 60 ||
|
||
plan.inventoryPurchaseSchedule.some(
|
||
(entry) =>
|
||
!entry ||
|
||
typeof entry !== 'object' ||
|
||
typeof entry.amount !== 'string' ||
|
||
!Number.isSafeInteger(entry.leadMonths) ||
|
||
entry.leadMonths < 0 ||
|
||
entry.leadMonths > 12 ||
|
||
entry.paymentLagMonths !== undefined,
|
||
)
|
||
)
|
||
return null;
|
||
}
|
||
if (
|
||
plan.useInventoryPurchaseSchedule === true &&
|
||
!Array.isArray(plan.inventoryPurchaseSchedule)
|
||
)
|
||
return null;
|
||
if (
|
||
[13, 14, 15, 16, 17].includes(Number(decoded.version)) &&
|
||
!Object.hasOwn(plan, 'monthlyVariableCostPaymentsByPeriod')
|
||
)
|
||
return null;
|
||
if (
|
||
plan.monthlyVariableCostPaymentsByPeriod !== undefined &&
|
||
plan.monthlyVariableCostPaymentsByPeriod !== null &&
|
||
(![13, 14, 15, 16, 17].includes(Number(decoded.version)) ||
|
||
!Array.isArray(plan.monthlyVariableCostPaymentsByPeriod) ||
|
||
plan.monthlyVariableCostPaymentsByPeriod.length <
|
||
Number(plan.periods) ||
|
||
plan.monthlyVariableCostPaymentsByPeriod.length > 60 ||
|
||
plan.monthlyVariableCostPaymentsByPeriod.some(
|
||
(value) => typeof value !== 'string',
|
||
))
|
||
)
|
||
return null;
|
||
if (
|
||
plan.salesCollectionLagMonths !== undefined &&
|
||
plan.salesCollectionLagMonths !== null &&
|
||
(!Number.isSafeInteger(plan.salesCollectionLagMonths) ||
|
||
Number(plan.salesCollectionLagMonths) < 0 ||
|
||
Number(plan.salesCollectionLagMonths) > 12)
|
||
)
|
||
return null;
|
||
if (
|
||
[4, 5, 6].includes(Number(decoded.version)) &&
|
||
!Object.hasOwn(plan, 'salesCollectionLagMonths')
|
||
)
|
||
return null;
|
||
if (
|
||
plan.salesCollectionPercent !== undefined &&
|
||
typeof plan.salesCollectionPercent !== 'string'
|
||
)
|
||
return null;
|
||
if (
|
||
[5, 6].includes(Number(decoded.version)) &&
|
||
!Object.hasOwn(plan, 'salesCollectionPercent')
|
||
)
|
||
return null;
|
||
const collectionPercent = String(plan.salesCollectionPercent ?? '100');
|
||
const collectionRate = percentMicro(
|
||
collectionPercent,
|
||
'Percentagem de cobrança',
|
||
);
|
||
if (collectionRate < 0n || collectionRate > 100_000_000n) return null;
|
||
if (
|
||
plan.openingReceivablesCollectionMonth !== undefined &&
|
||
plan.openingReceivablesCollectionMonth !== null &&
|
||
(!Number.isSafeInteger(plan.openingReceivablesCollectionMonth) ||
|
||
Number(plan.openingReceivablesCollectionMonth) < 1 ||
|
||
Number(plan.openingReceivablesCollectionMonth) > 60)
|
||
)
|
||
return null;
|
||
if (
|
||
plan.openingReceivablesCollectionPercent !== undefined &&
|
||
typeof plan.openingReceivablesCollectionPercent !== 'string'
|
||
)
|
||
return null;
|
||
if (
|
||
decoded.version === 6 &&
|
||
(!Object.hasOwn(plan, 'openingReceivablesCollectionMonth') ||
|
||
!Object.hasOwn(plan, 'openingReceivablesCollectionPercent'))
|
||
)
|
||
return null;
|
||
const openingCollectionRate = percentMicro(
|
||
String(plan.openingReceivablesCollectionPercent ?? '100'),
|
||
'Percentagem de cobrança dos valores a receber de abertura',
|
||
);
|
||
if (openingCollectionRate < 0n || openingCollectionRate > 100_000_000n)
|
||
return null;
|
||
if (
|
||
[7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17].includes(
|
||
Number(decoded.version),
|
||
)
|
||
) {
|
||
if (
|
||
!Array.isArray(plan.openingReceivablesInstallments) ||
|
||
plan.openingReceivablesInstallments.length > 60
|
||
)
|
||
return null;
|
||
let scheduledRate =
|
||
plan.openingReceivablesCollectionMonth === null ||
|
||
plan.openingReceivablesCollectionMonth === undefined
|
||
? 0n
|
||
: openingCollectionRate;
|
||
for (const entry of plan.openingReceivablesInstallments) {
|
||
if (
|
||
!entry ||
|
||
typeof entry !== 'object' ||
|
||
!Number.isSafeInteger(entry.month) ||
|
||
entry.month < 1 ||
|
||
entry.month > 60 ||
|
||
typeof entry.percent !== 'string'
|
||
)
|
||
return null;
|
||
const rate = percentMicro(
|
||
entry.percent,
|
||
'Percentagem da parcela de recebíveis',
|
||
);
|
||
if (rate < 0n || rate > 100_000_000n) return null;
|
||
scheduledRate += rate;
|
||
}
|
||
if (scheduledRate > 100_000_000n) return null;
|
||
}
|
||
if (
|
||
decoded.version === 17 &&
|
||
!Array.isArray(plan.openingPayablesInstallments)
|
||
)
|
||
return null;
|
||
if (
|
||
plan.openingPayablesInstallments !== undefined &&
|
||
(!Array.isArray(plan.openingPayablesInstallments) ||
|
||
plan.openingPayablesInstallments.length > 60 ||
|
||
(decoded.version !== 17 &&
|
||
plan.openingPayablesInstallments.length > 0) ||
|
||
plan.openingPayablesInstallments.some(
|
||
(entry) =>
|
||
!entry ||
|
||
typeof entry !== 'object' ||
|
||
!Number.isSafeInteger(entry.month) ||
|
||
entry.month < 1 ||
|
||
entry.month > 60 ||
|
||
typeof entry.percent !== 'string' ||
|
||
percentMicro(
|
||
entry.percent,
|
||
'Percentagem da liquidação de valores a pagar',
|
||
) < 0n ||
|
||
percentMicro(
|
||
entry.percent,
|
||
'Percentagem da liquidação de valores a pagar',
|
||
) > 100_000_000n,
|
||
) ||
|
||
plan.openingPayablesInstallments.reduce(
|
||
(sum, entry) =>
|
||
sum +
|
||
percentMicro(
|
||
entry.percent,
|
||
'Percentagem da liquidação de valores a pagar',
|
||
),
|
||
0n,
|
||
) > 100_000_000n)
|
||
)
|
||
return null;
|
||
if (
|
||
[8, 9, 10, 11, 12, 13, 14, 15, 16, 17].includes(
|
||
Number(decoded.version),
|
||
)
|
||
) {
|
||
if (
|
||
!Array.isArray(plan.salesRecoverySchedule) ||
|
||
plan.salesRecoverySchedule.length > 60
|
||
)
|
||
return null;
|
||
const months = new Set<number>();
|
||
for (const entry of plan.salesRecoverySchedule) {
|
||
if (
|
||
!entry ||
|
||
typeof entry !== 'object' ||
|
||
!Number.isSafeInteger(entry.month) ||
|
||
entry.month < 1 ||
|
||
entry.month > 60 ||
|
||
typeof entry.percent !== 'string' ||
|
||
months.has(entry.month)
|
||
)
|
||
return null;
|
||
months.add(entry.month);
|
||
const rate = percentMicro(
|
||
entry.percent,
|
||
'Percentagem de recuperação de vendas',
|
||
);
|
||
if (rate < 0n || rate > 100_000_000n) return null;
|
||
}
|
||
}
|
||
const scheduleFields = [
|
||
'monthlyOperatingExpensesByPeriod',
|
||
'monthlyOperatingPaymentsByPeriod',
|
||
'monthlyPayrollExpenseByPeriod',
|
||
'monthlyPayrollPaymentsByPeriod',
|
||
];
|
||
for (const field of scheduleFields) {
|
||
const schedule = plan[field];
|
||
if (
|
||
schedule !== undefined &&
|
||
(![11, 12, 13, 14, 15, 16, 17].includes(
|
||
Number(decoded.version),
|
||
) ||
|
||
!Array.isArray(schedule) ||
|
||
schedule.length < Number(plan.periods) ||
|
||
schedule.length > 60 ||
|
||
schedule.some((value) => typeof value !== 'string'))
|
||
)
|
||
return null;
|
||
}
|
||
for (const field of [
|
||
'presentationCurrency',
|
||
'fxRate',
|
||
'fxDate',
|
||
'fxSource',
|
||
])
|
||
if (plan[field] !== undefined && typeof plan[field] !== 'string')
|
||
return null;
|
||
if (
|
||
decoded.version >= 14 &&
|
||
typeof plan.useProductInventorySchedule !== 'boolean'
|
||
)
|
||
return null;
|
||
if (decoded.version < 14 && plan.useProductInventorySchedule === true)
|
||
return null;
|
||
if (
|
||
decoded.version >= 15 &&
|
||
typeof plan.useSupplierPaymentTerms !== 'boolean'
|
||
)
|
||
return null;
|
||
if (decoded.version < 15 && plan.useSupplierPaymentTerms === true)
|
||
return null;
|
||
if (
|
||
plan.useSupplierPaymentTerms === true &&
|
||
plan.useProductInventorySchedule !== true
|
||
)
|
||
return null;
|
||
if (plan.scenarioAdjustments !== undefined) {
|
||
const scenarios = plan.scenarioAdjustments as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
for (const name of ['favorable', 'unfavorable']) {
|
||
const adjustment = scenarios[name];
|
||
if (
|
||
!adjustment ||
|
||
typeof adjustment !== 'object' ||
|
||
[
|
||
'unitVolumePercent',
|
||
'unitPricePercent',
|
||
'unitCostPercent',
|
||
'operatingCostsPercent',
|
||
'cashCollectionsPercent',
|
||
].some(
|
||
(field) =>
|
||
typeof (adjustment as Record<string, unknown>)[
|
||
field
|
||
] !== 'string',
|
||
)
|
||
)
|
||
return null;
|
||
}
|
||
}
|
||
if (
|
||
!Array.isArray(plan.products) ||
|
||
plan.products.length === 0 ||
|
||
plan.products.some((product) => {
|
||
if (
|
||
!product ||
|
||
typeof product !== 'object' ||
|
||
[
|
||
'label',
|
||
'monthlyUnits',
|
||
'unitPrice',
|
||
'unitVariableCost',
|
||
].some(
|
||
(field) =>
|
||
typeof (product as Record<string, unknown>)[
|
||
field
|
||
] !== 'string',
|
||
)
|
||
)
|
||
return true;
|
||
const productData = product as Record<string, unknown>;
|
||
const schedule = productData.monthlyUnitsByPeriod;
|
||
const priceSchedule = productData.unitPriceByPeriod;
|
||
const costSchedule = productData.unitVariableCostByPeriod;
|
||
const validMoneySchedule = (values: unknown) =>
|
||
values === undefined ||
|
||
([10, 11, 12, 13, 14, 15, 16, 17].includes(
|
||
Number(decoded.version),
|
||
) &&
|
||
Array.isArray(values) &&
|
||
values.length >= Number(plan.periods) &&
|
||
values.length <= 60 &&
|
||
values.every((value) => typeof value === 'string'));
|
||
return (
|
||
(schedule !== undefined &&
|
||
(![9, 10, 11, 12, 13, 14, 15, 16, 17].includes(
|
||
Number(decoded.version),
|
||
) ||
|
||
!Array.isArray(schedule) ||
|
||
schedule.length < Number(plan.periods) ||
|
||
schedule.length > 60 ||
|
||
schedule.some(
|
||
(units) => typeof units !== 'string',
|
||
))) ||
|
||
!validMoneySchedule(priceSchedule) ||
|
||
!validMoneySchedule(costSchedule) ||
|
||
(productData.productInventoryOpening !== undefined &&
|
||
(Number(decoded.version) < 14 ||
|
||
typeof productData.productInventoryOpening !==
|
||
'string')) ||
|
||
(productData.productInventoryPurchasesByPeriod !==
|
||
undefined &&
|
||
(Number(decoded.version) < 14 ||
|
||
!Array.isArray(
|
||
productData.productInventoryPurchasesByPeriod,
|
||
) ||
|
||
productData.productInventoryPurchasesByPeriod
|
||
.length < Number(plan.periods) ||
|
||
productData.productInventoryPurchasesByPeriod
|
||
.length > 60 ||
|
||
productData.productInventoryPurchasesByPeriod.some(
|
||
(entry) =>
|
||
!entry ||
|
||
typeof entry.amount !== 'string' ||
|
||
!Number.isSafeInteger(entry.leadMonths) ||
|
||
entry.leadMonths < 0 ||
|
||
entry.leadMonths > 12 ||
|
||
(entry.paymentLagMonths !== undefined &&
|
||
(!Number.isSafeInteger(
|
||
entry.paymentLagMonths,
|
||
) ||
|
||
entry.paymentLagMonths < 0 ||
|
||
entry.paymentLagMonths > 12)) ||
|
||
(entry.paymentInstallments !== undefined &&
|
||
(![16, 17].includes(
|
||
Number(decoded.version),
|
||
) ||
|
||
!Array.isArray(
|
||
entry.paymentInstallments,
|
||
) ||
|
||
entry.paymentInstallments.length >
|
||
12 ||
|
||
entry.paymentInstallments.some(
|
||
(part: any) =>
|
||
!part ||
|
||
!Number.isSafeInteger(
|
||
part.lagMonths,
|
||
) ||
|
||
part.lagMonths < 0 ||
|
||
part.lagMonths > 12 ||
|
||
typeof part.percent !==
|
||
'string' ||
|
||
percentMicro(
|
||
part.percent,
|
||
'Percentagem da parcela',
|
||
) < 0n ||
|
||
percentMicro(
|
||
part.percent,
|
||
'Percentagem da parcela',
|
||
) > 100_000_000n,
|
||
) ||
|
||
entry.paymentInstallments.reduce(
|
||
(sum: bigint, part: any) =>
|
||
sum +
|
||
percentMicro(
|
||
part.percent,
|
||
'Percentagem da parcela',
|
||
),
|
||
0n,
|
||
) > 100_000_000n)),
|
||
))) ||
|
||
(plan.useProductInventorySchedule === true &&
|
||
(typeof productData.productInventoryOpening !==
|
||
'string' ||
|
||
!Array.isArray(
|
||
productData.productInventoryPurchasesByPeriod,
|
||
))) ||
|
||
(plan.useSupplierPaymentTerms === true &&
|
||
productData.productInventoryPurchasesByPeriod!.some(
|
||
(entry) =>
|
||
decoded.version === 15
|
||
? !Number.isSafeInteger(
|
||
entry.paymentLagMonths,
|
||
) ||
|
||
entry.paymentLagMonths < 0 ||
|
||
entry.paymentLagMonths > 12
|
||
: !Array.isArray(entry.paymentInstallments),
|
||
))
|
||
);
|
||
})
|
||
)
|
||
return null;
|
||
return {
|
||
...plan,
|
||
inventoryPurchaseLeadMonths: Number(
|
||
plan.inventoryPurchaseLeadMonths ?? 0,
|
||
),
|
||
salesCollectionLagMonths:
|
||
plan.salesCollectionLagMonths === null ||
|
||
plan.salesCollectionLagMonths === undefined
|
||
? null
|
||
: Number(plan.salesCollectionLagMonths),
|
||
salesCollectionPercent: String(
|
||
plan.salesCollectionPercent ?? '100',
|
||
),
|
||
openingReceivablesCollectionMonth:
|
||
plan.openingReceivablesCollectionMonth === null ||
|
||
plan.openingReceivablesCollectionMonth === undefined
|
||
? null
|
||
: Number(plan.openingReceivablesCollectionMonth),
|
||
openingReceivablesCollectionPercent: String(
|
||
plan.openingReceivablesCollectionPercent ?? '100',
|
||
),
|
||
openingReceivablesInstallments: Array.isArray(
|
||
plan.openingReceivablesInstallments,
|
||
)
|
||
? plan.openingReceivablesInstallments.map((entry) => ({
|
||
month: Number(entry.month),
|
||
percent: String(entry.percent),
|
||
}))
|
||
: [],
|
||
openingPayablesInstallments: Array.isArray(
|
||
plan.openingPayablesInstallments,
|
||
)
|
||
? plan.openingPayablesInstallments.map((entry) => ({
|
||
month: Number(entry.month),
|
||
percent: String(entry.percent),
|
||
}))
|
||
: [],
|
||
salesRecoverySchedule: Array.isArray(plan.salesRecoverySchedule)
|
||
? plan.salesRecoverySchedule.map((entry) => ({
|
||
month: Number(entry.month),
|
||
percent: String(entry.percent),
|
||
}))
|
||
: [],
|
||
inventoryPurchaseSchedule: Array.isArray(
|
||
plan.inventoryPurchaseSchedule,
|
||
)
|
||
? plan.inventoryPurchaseSchedule.map((entry) => ({
|
||
amount: String(entry.amount),
|
||
leadMonths: Number(entry.leadMonths),
|
||
}))
|
||
: null,
|
||
useInventoryPurchaseSchedule:
|
||
plan.useInventoryPurchaseSchedule === true,
|
||
useProductInventorySchedule:
|
||
plan.useProductInventorySchedule === true,
|
||
useSupplierPaymentTerms: plan.useSupplierPaymentTerms === true,
|
||
monthlyVariableCostPaymentsByPeriod: Array.isArray(
|
||
plan.monthlyVariableCostPaymentsByPeriod,
|
||
)
|
||
? plan.monthlyVariableCostPaymentsByPeriod.map(String)
|
||
: null,
|
||
} as unknown as BusinessFinancialPlanInput;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function cents(value: string, label: string, signed = false): bigint {
|
||
const match = (
|
||
signed ? /^(-?\d+)(?:\.(\d{1,2}))?$/ : /^(\d+)(?:\.(\d{1,2}))?$/
|
||
).exec(value.trim());
|
||
if (!match || value.length > 40)
|
||
throw new Error(
|
||
`${label}: use um montante${signed ? '' : ' não negativo'} com até duas casas decimais.`,
|
||
);
|
||
const sign = match[1].startsWith('-') ? -1n : 1n;
|
||
return (
|
||
sign *
|
||
(BigInt(match[1].replace('-', '')) * 100n +
|
||
BigInt((match[2] ?? '').padEnd(2, '0') || '0'))
|
||
);
|
||
}
|
||
|
||
function quantity(value: string): bigint {
|
||
const match = /^(\d+)(?:\.(\d{1,3}))?$/.exec(value.trim());
|
||
if (!match || value.length > 40)
|
||
throw new Error(
|
||
'Quantidade: use um valor não negativo com até três casas decimais.',
|
||
);
|
||
return (
|
||
BigInt(match[1]) * 1000n +
|
||
BigInt((match[2] ?? '').padEnd(3, '0') || '0')
|
||
);
|
||
}
|
||
|
||
function multiplyQuantity(
|
||
quantityMilli: bigint,
|
||
unitPriceCents: bigint,
|
||
): bigint {
|
||
return (quantityMilli * unitPriceCents + 500n) / 1000n;
|
||
}
|
||
|
||
/** Reuses the sales model, without requiring unrelated cost/cash assumptions. Blank is never zero. */
|
||
export function monthlyProjectedSales(input: BusinessFinancialPlanInput): Array<string | null> {
|
||
const periods = input.periods ?? 12;
|
||
if (!Number.isInteger(periods) || periods < 1 || periods > 60) throw new Error('Horizonte de vendas inválido.');
|
||
return Array.from({ length: periods }, (_, month) => {
|
||
if (!input.products.length) return null;
|
||
let total = 0n;
|
||
for (const product of input.products) {
|
||
const units = product.monthlyUnitsByPeriod === undefined ? product.monthlyUnits : product.monthlyUnitsByPeriod[month];
|
||
const price = product.unitPriceByPeriod === undefined ? product.unitPrice : product.unitPriceByPeriod[month];
|
||
if (typeof units !== 'string' || typeof price !== 'string' || units.trim() === '' || price.trim() === '') return null;
|
||
total += multiplyQuantity(quantity(units), cents(price, `Preço de ${product.label}`));
|
||
}
|
||
return format(total);
|
||
});
|
||
}
|
||
|
||
/** Existing purchase orders are planning references, never a proxy for deductible VAT or invoice dates. */
|
||
export function monthlyProjectedPurchaseOrders(input: BusinessFinancialPlanInput): Array<string | null> {
|
||
const periods = input.periods ?? 12;
|
||
if (!Number.isInteger(periods) || periods < 1 || periods > 60) throw new Error('Horizonte de compras inválido.');
|
||
return Array.from({length: periods}, (_, month) => {
|
||
const amounts = input.useProductInventorySchedule
|
||
? input.products.map((product) => product.productInventoryPurchasesByPeriod?.[month]?.amount)
|
||
: [input.useInventoryPurchaseSchedule
|
||
? input.inventoryPurchaseSchedule?.[month]?.amount : input.monthlyInventoryPurchases];
|
||
if (!amounts.length || amounts.some((amount) => typeof amount !== 'string' || amount.trim() === '')) return null;
|
||
return format(amounts.reduce((total, amount) => total + cents(amount!, 'Compras projectadas'), 0n));
|
||
});
|
||
}
|
||
|
||
function format(centsValue: bigint): string {
|
||
const absolute = centsValue < 0n ? -centsValue : centsValue;
|
||
return `${centsValue < 0n ? '-' : ''}${absolute / 100n}.${String(absolute % 100n).padStart(2, '0')}`;
|
||
}
|
||
|
||
function scaledDecimal(value: string, scale: number, label: string): bigint {
|
||
const match = /^(\d+)(?:\.(\d+))?$/.exec(value.trim());
|
||
if (!match || (match[2]?.length ?? 0) > scale || value.length > 40)
|
||
throw new Error(
|
||
`${label}: use um decimal positivo com até ${scale} casas.`,
|
||
);
|
||
return (
|
||
BigInt(match[1]) * 10n ** BigInt(scale) +
|
||
BigInt((match[2] ?? '').padEnd(scale, '0') || '0')
|
||
);
|
||
}
|
||
|
||
export function convertCents(
|
||
value: bigint,
|
||
rateScaled: bigint,
|
||
rateScale: bigint,
|
||
): bigint {
|
||
const sign = value < 0n ? -1n : 1n;
|
||
const absolute = value < 0n ? -value : value;
|
||
return sign * ((absolute * rateScaled + rateScale / 2n) / rateScale);
|
||
}
|
||
|
||
function percentMicro(value: string, label: string): bigint {
|
||
const match = /^(-?\d+)(?:\.(\d{1,6}))?$/.exec(value.trim());
|
||
if (!match || value.length > 40)
|
||
throw new Error(
|
||
`${label}: indique uma percentagem com até seis casas.`,
|
||
);
|
||
const sign = match[1].startsWith('-') ? -1n : 1n;
|
||
return (
|
||
sign *
|
||
(BigInt(match[1].replace('-', '')) * 1_000_000n +
|
||
BigInt((match[2] ?? '').padEnd(6, '0') || '0'))
|
||
);
|
||
}
|
||
|
||
function formatPercentMicro(rate: bigint): string {
|
||
return `${rate / 1_000_000n}.${String(rate % 1_000_000n)
|
||
.padStart(6, '0')
|
||
.replace(/0+$/, '')}`.replace(/\.$/, '');
|
||
}
|
||
|
||
function adjustPercent(
|
||
value: bigint,
|
||
percentage: string,
|
||
label: string,
|
||
): bigint {
|
||
const factor = 100_000_000n + percentMicro(percentage, label);
|
||
if (factor < 0n)
|
||
throw new Error(`${label}: a redução não pode exceder 100%.`);
|
||
const absolute = value < 0n ? -value : value;
|
||
const adjusted = (absolute * factor + 50_000_000n) / 100_000_000n;
|
||
return value < 0n ? -adjusted : adjusted;
|
||
}
|
||
|
||
function formatQuantity(quantityMilli: bigint): string {
|
||
return `${quantityMilli / 1000n}.${String(quantityMilli % 1000n).padStart(3, '0')}`;
|
||
}
|
||
|
||
function applyScenario(
|
||
base: BusinessFinancialPlanInput,
|
||
adjustment: ScenarioAdjustment,
|
||
): BusinessFinancialPlanInput {
|
||
const volume = (value: string, label: string) =>
|
||
formatQuantity(
|
||
adjustPercent(
|
||
quantity(value),
|
||
adjustment.unitVolumePercent,
|
||
`${label}: volume`,
|
||
),
|
||
);
|
||
const moneyChange = (value: string, percentage: string, label: string) =>
|
||
format(adjustPercent(cents(value, label), percentage, label));
|
||
const baseCollectionRate = percentMicro(
|
||
base.salesCollectionPercent ?? '100',
|
||
'Percentagem de cobrança',
|
||
);
|
||
const scenarioCollectionFactor =
|
||
100_000_000n +
|
||
percentMicro(
|
||
adjustment.cashCollectionsPercent,
|
||
'Variação das cobranças',
|
||
);
|
||
const rawAdjustedCollectionRate =
|
||
(baseCollectionRate * scenarioCollectionFactor + 50_000_000n) /
|
||
100_000_000n;
|
||
const adjustedCollectionRate =
|
||
rawAdjustedCollectionRate < 0n
|
||
? 0n
|
||
: rawAdjustedCollectionRate > 100_000_000n
|
||
? 100_000_000n
|
||
: rawAdjustedCollectionRate;
|
||
const adjustedCollectionPercent = formatPercentMicro(
|
||
adjustedCollectionRate,
|
||
);
|
||
const baseOpeningReceivableRate = percentMicro(
|
||
base.openingReceivablesCollectionPercent ?? '100',
|
||
'Percentagem de cobrança dos valores a receber de abertura',
|
||
);
|
||
const rawAdjustedOpeningReceivableRate =
|
||
(baseOpeningReceivableRate * scenarioCollectionFactor + 50_000_000n) /
|
||
100_000_000n;
|
||
const adjustedOpeningReceivableRate =
|
||
rawAdjustedOpeningReceivableRate < 0n
|
||
? 0n
|
||
: rawAdjustedOpeningReceivableRate > 100_000_000n
|
||
? 100_000_000n
|
||
: rawAdjustedOpeningReceivableRate;
|
||
const openingReceivablePercent =
|
||
base.openingReceivablesCollectionMonth === null ||
|
||
base.openingReceivablesCollectionMonth === undefined
|
||
? (base.openingReceivablesCollectionPercent ?? '100')
|
||
: formatPercentMicro(adjustedOpeningReceivableRate);
|
||
let remainingOpeningCollectionRate =
|
||
100_000_000n -
|
||
(base.openingReceivablesCollectionMonth === null ||
|
||
base.openingReceivablesCollectionMonth === undefined
|
||
? 0n
|
||
: adjustedOpeningReceivableRate);
|
||
const openingReceivablesInstallments = (
|
||
base.openingReceivablesInstallments ?? []
|
||
).map((installment) => {
|
||
const declaredRate = percentMicro(
|
||
installment.percent,
|
||
'Percentagem da parcela de recebíveis',
|
||
);
|
||
const adjustedRate =
|
||
(declaredRate * scenarioCollectionFactor + 50_000_000n) /
|
||
100_000_000n;
|
||
const payableRate =
|
||
adjustedRate < 0n
|
||
? 0n
|
||
: adjustedRate > remainingOpeningCollectionRate
|
||
? remainingOpeningCollectionRate
|
||
: adjustedRate;
|
||
remainingOpeningCollectionRate -= payableRate;
|
||
return { ...installment, percent: formatPercentMicro(payableRate) };
|
||
});
|
||
const salesRecoverySchedule = (base.salesRecoverySchedule ?? []).map(
|
||
(entry) => {
|
||
const declared = percentMicro(
|
||
entry.percent,
|
||
'Percentagem de recuperação de vendas',
|
||
);
|
||
const adjusted =
|
||
(declared * scenarioCollectionFactor + 50_000_000n) /
|
||
100_000_000n;
|
||
return {
|
||
...entry,
|
||
percent: formatPercentMicro(
|
||
adjusted < 0n
|
||
? 0n
|
||
: adjusted > 100_000_000n
|
||
? 100_000_000n
|
||
: adjusted,
|
||
),
|
||
};
|
||
},
|
||
);
|
||
return {
|
||
...base,
|
||
products: base.products.map((product) => ({
|
||
...product,
|
||
monthlyUnits: volume(product.monthlyUnits, product.label),
|
||
monthlyUnitsByPeriod: product.monthlyUnitsByPeriod?.map(
|
||
(units, index) =>
|
||
volume(units, `${product.label}: mês ${index + 1}`),
|
||
),
|
||
unitPrice: moneyChange(
|
||
product.unitPrice,
|
||
adjustment.unitPricePercent,
|
||
`${product.label}: preço`,
|
||
),
|
||
unitPriceByPeriod: product.unitPriceByPeriod?.map((price, index) =>
|
||
moneyChange(
|
||
price,
|
||
adjustment.unitPricePercent,
|
||
`${product.label}: preço mês ${index + 1}`,
|
||
),
|
||
),
|
||
unitVariableCost: moneyChange(
|
||
product.unitVariableCost,
|
||
adjustment.unitCostPercent,
|
||
`${product.label}: custo unitário`,
|
||
),
|
||
unitVariableCostByPeriod: product.unitVariableCostByPeriod?.map(
|
||
(cost, index) =>
|
||
moneyChange(
|
||
cost,
|
||
adjustment.unitCostPercent,
|
||
`${product.label}: custo unitário mês ${index + 1}`,
|
||
),
|
||
),
|
||
productInventoryPurchasesByPeriod:
|
||
base.useProductInventorySchedule === true
|
||
? product.productInventoryPurchasesByPeriod?.map(
|
||
(entry, month) => ({
|
||
...entry,
|
||
amount:
|
||
month >= (base.periods ?? 12)
|
||
? entry.amount
|
||
: moneyChange(
|
||
moneyChange(
|
||
entry.amount,
|
||
adjustment.unitVolumePercent,
|
||
`${product.label}: stock volume mes ${month + 1}`,
|
||
),
|
||
adjustment.unitCostPercent,
|
||
`${product.label}: compra mes ${month + 1}`,
|
||
),
|
||
}),
|
||
)
|
||
: product.productInventoryPurchasesByPeriod,
|
||
})),
|
||
monthlyVariableCostPayments: moneyChange(
|
||
moneyChange(
|
||
base.monthlyVariableCostPayments,
|
||
adjustment.unitVolumePercent,
|
||
'Volume de pagamentos variáveis',
|
||
),
|
||
adjustment.unitCostPercent,
|
||
'Custo dos pagamentos variáveis',
|
||
),
|
||
monthlyVariableCostPaymentsByPeriod:
|
||
base.monthlyVariableCostPaymentsByPeriod?.map((amount, month) =>
|
||
moneyChange(
|
||
moneyChange(
|
||
amount,
|
||
adjustment.unitVolumePercent,
|
||
`Volume dos pagamentos de compras mês ${month + 1}`,
|
||
),
|
||
adjustment.unitCostPercent,
|
||
`Pagamento de compras mês ${month + 1}`,
|
||
),
|
||
) ?? null,
|
||
monthlyInventoryPurchases:
|
||
base.monthlyInventoryPurchases === undefined ||
|
||
base.monthlyInventoryPurchases.trim() === ''
|
||
? undefined
|
||
: moneyChange(
|
||
moneyChange(
|
||
base.monthlyInventoryPurchases,
|
||
adjustment.unitVolumePercent,
|
||
'Volume de compras para existências',
|
||
),
|
||
adjustment.unitCostPercent,
|
||
'Compras para existências',
|
||
),
|
||
inventoryPurchaseSchedule:
|
||
base.useInventoryPurchaseSchedule === true
|
||
? base.inventoryPurchaseSchedule?.map((entry, index) => ({
|
||
...entry,
|
||
amount:
|
||
index >= (base.periods ?? 12)
|
||
? entry.amount
|
||
: moneyChange(
|
||
moneyChange(
|
||
entry.amount,
|
||
adjustment.unitVolumePercent,
|
||
`Compras para existências, volume mês ${index + 1}`,
|
||
),
|
||
adjustment.unitCostPercent,
|
||
`Compras para existências mês ${index + 1}`,
|
||
),
|
||
}))
|
||
: base.inventoryPurchaseSchedule,
|
||
monthlyOperatingExpenses: moneyChange(
|
||
base.monthlyOperatingExpenses,
|
||
adjustment.operatingCostsPercent,
|
||
'Despesas operacionais',
|
||
),
|
||
monthlyOperatingExpensesByPeriod:
|
||
base.monthlyOperatingExpensesByPeriod?.map((amount, month) =>
|
||
moneyChange(
|
||
amount,
|
||
adjustment.operatingCostsPercent,
|
||
`Despesas operacionais mes ${month + 1}`,
|
||
),
|
||
),
|
||
monthlyOperatingPayments: moneyChange(
|
||
base.monthlyOperatingPayments,
|
||
adjustment.operatingCostsPercent,
|
||
'Pagamentos operacionais',
|
||
),
|
||
monthlyOperatingPaymentsByPeriod:
|
||
base.monthlyOperatingPaymentsByPeriod?.map((amount, month) =>
|
||
moneyChange(
|
||
amount,
|
||
adjustment.operatingCostsPercent,
|
||
`Pagamentos operacionais mes ${month + 1}`,
|
||
),
|
||
),
|
||
monthlyPayrollExpense: moneyChange(
|
||
base.monthlyPayrollExpense,
|
||
adjustment.operatingCostsPercent,
|
||
'Pessoal',
|
||
),
|
||
monthlyPayrollExpenseByPeriod: base.monthlyPayrollExpenseByPeriod?.map(
|
||
(amount, month) =>
|
||
moneyChange(
|
||
amount,
|
||
adjustment.operatingCostsPercent,
|
||
`Despesa com pessoal mes ${month + 1}`,
|
||
),
|
||
),
|
||
monthlyPayrollPayments: moneyChange(
|
||
base.monthlyPayrollPayments,
|
||
adjustment.operatingCostsPercent,
|
||
'Pagamentos de pessoal',
|
||
),
|
||
monthlyPayrollPaymentsByPeriod:
|
||
base.monthlyPayrollPaymentsByPeriod?.map((amount, month) =>
|
||
moneyChange(
|
||
amount,
|
||
adjustment.operatingCostsPercent,
|
||
`Pagamentos de pessoal mes ${month + 1}`,
|
||
),
|
||
),
|
||
monthlyCollections: moneyChange(
|
||
base.monthlyCollections,
|
||
adjustment.cashCollectionsPercent,
|
||
'Cobranças',
|
||
),
|
||
salesCollectionPercent: adjustedCollectionPercent,
|
||
openingReceivablesCollectionPercent: openingReceivablePercent,
|
||
openingReceivablesInstallments,
|
||
salesRecoverySchedule,
|
||
};
|
||
}
|
||
|
||
export function compareBusinessScenarios(input: BusinessFinancialPlanInput) {
|
||
if (!input.scenarioAdjustments)
|
||
throw new Error(
|
||
'Indique os pressupostos para os cenários favorável e desfavorável.',
|
||
);
|
||
const base = forecastBusinessPlan(input);
|
||
const favorableInputs = applyScenario(
|
||
input,
|
||
input.scenarioAdjustments.favorable,
|
||
);
|
||
const unfavorableInputs = applyScenario(
|
||
input,
|
||
input.scenarioAdjustments.unfavorable,
|
||
);
|
||
return {
|
||
base,
|
||
favorable: {
|
||
adjustments: input.scenarioAdjustments.favorable,
|
||
result: forecastBusinessPlan(favorableInputs),
|
||
},
|
||
unfavorable: {
|
||
adjustments: input.scenarioAdjustments.unfavorable,
|
||
result: forecastBusinessPlan(unfavorableInputs),
|
||
},
|
||
changedAssumptions: [
|
||
'unidades vendidas',
|
||
'preço unitário',
|
||
'custo variável unitário',
|
||
'compras para existências',
|
||
'despesas e pessoal',
|
||
'cobranças',
|
||
],
|
||
};
|
||
}
|
||
|
||
/** Forecast separates invoiced sales, collections, accounting expenses and cash payments. */
|
||
export function forecastBusinessPlan(input: BusinessFinancialPlanInput) {
|
||
if (!validCurrency(input.currency))
|
||
throw new Error('Seleccione um código de moeda ISO suportado.');
|
||
const periods = input.periods ?? 12;
|
||
if (!Number.isSafeInteger(periods) || periods < 1 || periods > 60)
|
||
throw new Error('O horizonte deve estar entre 1 e 60 meses.');
|
||
if (
|
||
!Number.isSafeInteger(input.depreciationLifeMonths) ||
|
||
input.depreciationLifeMonths < 1 ||
|
||
input.depreciationLifeMonths > 600
|
||
)
|
||
throw new Error(
|
||
'A vida útil contabilística deve estar entre 1 e 600 meses.',
|
||
);
|
||
const inventoryPurchaseLeadMonths = input.inventoryPurchaseLeadMonths ?? 0;
|
||
if (
|
||
!Number.isSafeInteger(inventoryPurchaseLeadMonths) ||
|
||
inventoryPurchaseLeadMonths < 0 ||
|
||
inventoryPurchaseLeadMonths > 12
|
||
)
|
||
throw new Error(
|
||
'O prazo de entrega das compras deve estar entre 0 e 12 meses.',
|
||
);
|
||
const inventoryPurchaseSchedule = input.inventoryPurchaseSchedule ?? null;
|
||
if (
|
||
inventoryPurchaseSchedule !== null &&
|
||
(!Array.isArray(inventoryPurchaseSchedule) ||
|
||
inventoryPurchaseSchedule.length < periods ||
|
||
inventoryPurchaseSchedule.length > 60 ||
|
||
inventoryPurchaseSchedule.some(
|
||
(entry) =>
|
||
!entry ||
|
||
typeof entry.amount !== 'string' ||
|
||
!Number.isSafeInteger(entry.leadMonths) ||
|
||
entry.leadMonths < 0 ||
|
||
entry.leadMonths > 12,
|
||
))
|
||
)
|
||
throw new Error(
|
||
`A agenda de compras deve cobrir os ${periods} meses e usar prazos de entrega entre 0 e 12 meses.`,
|
||
);
|
||
const useInventoryPurchaseSchedule =
|
||
input.useInventoryPurchaseSchedule === true;
|
||
const useProductInventorySchedule =
|
||
input.useProductInventorySchedule === true;
|
||
const useSupplierPaymentTerms = input.useSupplierPaymentTerms === true;
|
||
if (useSupplierPaymentTerms && !useProductInventorySchedule)
|
||
throw new Error(
|
||
'Os prazos de pagamento a fornecedores exigem compras planeadas por produto.',
|
||
);
|
||
if (useInventoryPurchaseSchedule && useProductInventorySchedule)
|
||
throw new Error(
|
||
'Seleccione compras agregadas ou stock por produto, nao os dois modos.',
|
||
);
|
||
if (useInventoryPurchaseSchedule && inventoryPurchaseSchedule === null)
|
||
throw new Error('Indique a agenda mensal de compras para a utilizar.');
|
||
const salesCollectionLagMonths = input.salesCollectionLagMonths ?? null;
|
||
if (
|
||
salesCollectionLagMonths !== null &&
|
||
(!Number.isSafeInteger(salesCollectionLagMonths) ||
|
||
salesCollectionLagMonths < 0 ||
|
||
salesCollectionLagMonths > 12)
|
||
)
|
||
throw new Error(
|
||
'O prazo de cobrança das vendas deve estar entre 0 e 12 meses.',
|
||
);
|
||
const salesCollectionPercent = input.salesCollectionPercent ?? '100';
|
||
const salesCollectionRate = percentMicro(
|
||
salesCollectionPercent,
|
||
'Percentagem de cobrança',
|
||
);
|
||
if (salesCollectionRate < 0n || salesCollectionRate > 100_000_000n)
|
||
throw new Error('A percentagem de cobrança deve estar entre 0 e 100.');
|
||
const openingReceivablesCollectionMonth =
|
||
input.openingReceivablesCollectionMonth ?? null;
|
||
if (
|
||
openingReceivablesCollectionMonth !== null &&
|
||
(!Number.isSafeInteger(openingReceivablesCollectionMonth) ||
|
||
openingReceivablesCollectionMonth < 1 ||
|
||
openingReceivablesCollectionMonth > periods)
|
||
)
|
||
throw new Error(
|
||
'O mês de cobrança dos valores a receber deve estar dentro do horizonte da simulação.',
|
||
);
|
||
const openingReceivablesCollectionPercent =
|
||
input.openingReceivablesCollectionPercent ?? '100';
|
||
const openingReceivablesCollectionRate = percentMicro(
|
||
openingReceivablesCollectionPercent,
|
||
'Percentagem de cobrança dos valores a receber de abertura',
|
||
);
|
||
if (
|
||
openingReceivablesCollectionRate < 0n ||
|
||
openingReceivablesCollectionRate > 100_000_000n
|
||
)
|
||
throw new Error(
|
||
'A percentagem de cobrança dos valores a receber deve estar entre 0 e 100.',
|
||
);
|
||
const openingReceivablesInstallments =
|
||
input.openingReceivablesInstallments ?? [];
|
||
if (
|
||
!Array.isArray(openingReceivablesInstallments) ||
|
||
openingReceivablesInstallments.length > 60
|
||
)
|
||
throw new Error('A agenda de recebíveis deve ter até 60 parcelas.');
|
||
let openingReceivablesScheduledRate =
|
||
openingReceivablesCollectionMonth === null
|
||
? 0n
|
||
: openingReceivablesCollectionRate;
|
||
const openingReceivablesInstallmentRates =
|
||
openingReceivablesInstallments.map((installment) => {
|
||
if (
|
||
!Number.isSafeInteger(installment.month) ||
|
||
installment.month < 1 ||
|
||
installment.month > periods
|
||
)
|
||
throw new Error(
|
||
'Cada parcela de recebíveis deve estar dentro do horizonte da simulação.',
|
||
);
|
||
const rate = percentMicro(
|
||
installment.percent,
|
||
'Percentagem da parcela de recebíveis',
|
||
);
|
||
if (rate < 0n || rate > 100_000_000n)
|
||
throw new Error(
|
||
'A percentagem de cada parcela deve estar entre 0 e 100.',
|
||
);
|
||
openingReceivablesScheduledRate += rate;
|
||
return rate;
|
||
});
|
||
if (openingReceivablesScheduledRate > 100_000_000n)
|
||
throw new Error(
|
||
'A soma das cobranças declaradas de recebíveis de abertura não pode exceder 100%.',
|
||
);
|
||
const openingPayablesInstallments = input.openingPayablesInstallments ?? [];
|
||
if (
|
||
!Array.isArray(openingPayablesInstallments) ||
|
||
openingPayablesInstallments.length > 60
|
||
)
|
||
throw new Error(
|
||
'A agenda de valores a pagar deve ter até 60 parcelas.',
|
||
);
|
||
let openingPayablesScheduledRate = 0n;
|
||
const openingPayablesInstallmentRates = openingPayablesInstallments.map(
|
||
(installment) => {
|
||
if (
|
||
!Number.isSafeInteger(installment.month) ||
|
||
installment.month < 1 ||
|
||
installment.month > periods
|
||
)
|
||
throw new Error(
|
||
'Cada parcela de valores a pagar deve estar dentro do horizonte da simulação.',
|
||
);
|
||
const rate = percentMicro(
|
||
installment.percent,
|
||
'Percentagem da liquidação de valores a pagar',
|
||
);
|
||
if (rate < 0n || rate > 100_000_000n)
|
||
throw new Error(
|
||
'A percentagem de cada liquidação deve estar entre 0 e 100.',
|
||
);
|
||
openingPayablesScheduledRate += rate;
|
||
return rate;
|
||
},
|
||
);
|
||
if (openingPayablesScheduledRate > 100_000_000n)
|
||
throw new Error(
|
||
'A soma das liquidações declaradas de valores a pagar não pode exceder 100%.',
|
||
);
|
||
const salesRecoverySchedule = input.salesRecoverySchedule ?? [];
|
||
if (
|
||
!Array.isArray(salesRecoverySchedule) ||
|
||
salesRecoverySchedule.length > 60
|
||
)
|
||
throw new Error('A agenda de recuperação deve ter até 60 parcelas.');
|
||
const recoveryMonths = new Set<number>();
|
||
const salesRecoveryRates = new Map<number, bigint>();
|
||
salesRecoverySchedule.forEach((entry) => {
|
||
if (
|
||
!Number.isSafeInteger(entry.month) ||
|
||
entry.month < 1 ||
|
||
entry.month > periods ||
|
||
recoveryMonths.has(entry.month)
|
||
)
|
||
throw new Error(
|
||
'Cada recuperação deve ter um mês único dentro do horizonte.',
|
||
);
|
||
recoveryMonths.add(entry.month);
|
||
const rate = percentMicro(
|
||
entry.percent,
|
||
'Percentagem de recuperação de vendas',
|
||
);
|
||
if (rate < 0n || rate > 100_000_000n)
|
||
throw new Error(
|
||
'A percentagem de recuperação deve estar entre 0 e 100.',
|
||
);
|
||
salesRecoveryRates.set(entry.month, rate);
|
||
});
|
||
if (!input.products.length)
|
||
throw new Error('Adicione pelo menos um produto ou serviço.');
|
||
|
||
const presentationCurrency = input.presentationCurrency || input.currency;
|
||
if (!validCurrency(presentationCurrency))
|
||
throw new Error('Seleccione uma moeda de apresentação ISO suportada.');
|
||
let fxRate: bigint | null = null;
|
||
if (presentationCurrency !== input.currency) {
|
||
if (!input.fxRate || !input.fxDate || !input.fxSource?.trim())
|
||
throw new Error(
|
||
'Para converter moeda, indique taxa, data efectiva e fonte da taxa de câmbio.',
|
||
);
|
||
fxRate = scaledDecimal(input.fxRate, 8, 'Taxa de câmbio');
|
||
if (fxRate === 0n)
|
||
throw new Error('A taxa de câmbio deve ser superior a zero.');
|
||
const effectiveDate = /^\d{4}-\d{2}-\d{2}$/.test(input.fxDate)
|
||
? new Date(`${input.fxDate}T00:00:00Z`)
|
||
: null;
|
||
if (
|
||
!effectiveDate ||
|
||
Number.isNaN(effectiveDate.getTime()) ||
|
||
effectiveDate.toISOString().slice(0, 10) !== input.fxDate
|
||
)
|
||
throw new Error(
|
||
'Indique uma data de câmbio válida no formato AAAA-MM-DD.',
|
||
);
|
||
if (input.fxSource.trim().length > 2000)
|
||
throw new Error(
|
||
'A fonte da taxa de câmbio excede o limite de caracteres.',
|
||
);
|
||
}
|
||
|
||
const productInputs = input.products.map((product, index) => {
|
||
const unitsByPeriod = product.monthlyUnitsByPeriod;
|
||
const priceSchedule = product.unitPriceByPeriod;
|
||
const costSchedule = product.unitVariableCostByPeriod;
|
||
if (
|
||
unitsByPeriod !== undefined &&
|
||
(!Array.isArray(unitsByPeriod) ||
|
||
unitsByPeriod.length < periods ||
|
||
unitsByPeriod.length > 60)
|
||
)
|
||
throw new Error(
|
||
`O plano de unidades do produto ${index + 1} deve cobrir os ${periods} meses do horizonte.`,
|
||
);
|
||
const parseMoneySchedule = (
|
||
schedule: string[] | undefined,
|
||
baseValue: bigint,
|
||
label: string,
|
||
) => {
|
||
if (schedule === undefined) return Array(periods).fill(baseValue);
|
||
if (
|
||
!Array.isArray(schedule) ||
|
||
schedule.length < periods ||
|
||
schedule.length > 60
|
||
)
|
||
throw new Error(
|
||
`${label}: a agenda deve cobrir os ${periods} meses do horizonte e ter no máximo 60 meses.`,
|
||
);
|
||
return schedule.map((amount, month) =>
|
||
cents(amount, `${label}, mês ${month + 1}`),
|
||
);
|
||
};
|
||
const basePrice = cents(
|
||
product.unitPrice,
|
||
`Preço do produto ${index + 1}`,
|
||
);
|
||
const baseVariableCost = cents(
|
||
product.unitVariableCost,
|
||
`Custo variável do produto ${index + 1}`,
|
||
);
|
||
const _productInventoryOpening = useProductInventorySchedule
|
||
? cents(
|
||
product.productInventoryOpening ?? '',
|
||
`Opening inventory for ${product.label}`,
|
||
)
|
||
: null;
|
||
const productPurchaseSchedule =
|
||
product.productInventoryPurchasesByPeriod ?? null;
|
||
if (
|
||
useProductInventorySchedule &&
|
||
(!Array.isArray(productPurchaseSchedule) ||
|
||
productPurchaseSchedule.length < periods ||
|
||
productPurchaseSchedule.length > 60 ||
|
||
productPurchaseSchedule.some(
|
||
(entry) =>
|
||
!entry ||
|
||
typeof entry.amount !== 'string' ||
|
||
!Number.isSafeInteger(entry.leadMonths) ||
|
||
entry.leadMonths < 0 ||
|
||
entry.leadMonths > 12,
|
||
))
|
||
)
|
||
throw new Error(
|
||
`${product.label}: declare purchases and lead times for all ${periods} months.`,
|
||
);
|
||
return {
|
||
units:
|
||
unitsByPeriod?.map(quantity) ??
|
||
Array(periods).fill(quantity(product.monthlyUnits)),
|
||
prices: parseMoneySchedule(
|
||
priceSchedule,
|
||
basePrice,
|
||
`Preço do produto ${index + 1}`,
|
||
),
|
||
costs: parseMoneySchedule(
|
||
costSchedule,
|
||
baseVariableCost,
|
||
`Custo variável do produto ${index + 1}`,
|
||
),
|
||
};
|
||
});
|
||
const productStockInputs = input.products.map((product, _index) => {
|
||
if (!useProductInventorySchedule)
|
||
return { opening: null, orders: null };
|
||
const opening = cents(
|
||
product.productInventoryOpening ?? '',
|
||
`Existencias iniciais de ${product.label}`,
|
||
);
|
||
const schedule = product.productInventoryPurchasesByPeriod;
|
||
if (
|
||
!Array.isArray(schedule) ||
|
||
schedule.length < periods ||
|
||
schedule.length > 60 ||
|
||
schedule.some(
|
||
(entry) =>
|
||
!entry ||
|
||
typeof entry.amount !== 'string' ||
|
||
!Number.isSafeInteger(entry.leadMonths) ||
|
||
entry.leadMonths < 0 ||
|
||
entry.leadMonths > 12 ||
|
||
(useSupplierPaymentTerms &&
|
||
(entry.paymentInstallments !== undefined
|
||
? !Array.isArray(entry.paymentInstallments) ||
|
||
entry.paymentInstallments.length > 12 ||
|
||
entry.paymentInstallments.some(
|
||
(part) =>
|
||
!Number.isSafeInteger(part.lagMonths) ||
|
||
part.lagMonths < 0 ||
|
||
part.lagMonths > 12 ||
|
||
typeof part.percent !== 'string' ||
|
||
percentMicro(
|
||
part.percent,
|
||
'Percentagem da parcela',
|
||
) < 0n ||
|
||
percentMicro(
|
||
part.percent,
|
||
'Percentagem da parcela',
|
||
) > 100_000_000n,
|
||
) ||
|
||
entry.paymentInstallments.reduce(
|
||
(sum, part) =>
|
||
sum +
|
||
percentMicro(
|
||
part.percent,
|
||
'Percentagem da parcela',
|
||
),
|
||
0n,
|
||
) > 100_000_000n
|
||
: !Number.isSafeInteger(entry.paymentLagMonths) ||
|
||
entry.paymentLagMonths! < 0 ||
|
||
entry.paymentLagMonths! > 12)),
|
||
)
|
||
)
|
||
throw new Error(
|
||
`${product.label}: declare encomendas e prazos de entrega para todos os ${periods} meses.`,
|
||
);
|
||
return {
|
||
opening,
|
||
orders: schedule.slice(0, periods).map((entry, month) => ({
|
||
amount: cents(
|
||
entry.amount,
|
||
`${product.label}: compra para existencias, mes ${month + 1}`,
|
||
),
|
||
leadMonths: entry.leadMonths,
|
||
paymentLagMonths: entry.paymentLagMonths ?? 0,
|
||
paymentInstallments: entry.paymentInstallments ?? [
|
||
{
|
||
lagMonths: entry.paymentLagMonths ?? 0,
|
||
percent: '100',
|
||
},
|
||
],
|
||
})),
|
||
};
|
||
});
|
||
const revenueByPeriod = Array<bigint>(periods).fill(0n);
|
||
const costOfSalesByPeriod = Array<bigint>(periods).fill(0n);
|
||
for (const product of productInputs) {
|
||
for (let period = 0; period < periods; period++) {
|
||
revenueByPeriod[period] += multiplyQuantity(
|
||
product.units[period],
|
||
product.prices[period],
|
||
);
|
||
costOfSalesByPeriod[period] += multiplyQuantity(
|
||
product.units[period],
|
||
product.costs[period],
|
||
);
|
||
}
|
||
}
|
||
const totalRevenue = revenueByPeriod.reduce(
|
||
(total, value) => total + value,
|
||
0n,
|
||
);
|
||
const totalCostOfSales = costOfSalesByPeriod.reduce(
|
||
(total, value) => total + value,
|
||
0n,
|
||
);
|
||
const monthlyCollections = cents(
|
||
input.monthlyCollections,
|
||
'Cobranças mensais',
|
||
);
|
||
const variablePayments = cents(
|
||
input.monthlyVariableCostPayments,
|
||
'Pagamentos de compras',
|
||
);
|
||
const monthlyInventoryPurchases = input.monthlyInventoryPurchases?.trim()
|
||
? cents(
|
||
input.monthlyInventoryPurchases,
|
||
'Compras mensais para existências',
|
||
)
|
||
: null;
|
||
const inventoryPurchasesByPeriod = useProductInventorySchedule
|
||
? Array.from({ length: periods }, (_, month) =>
|
||
productStockInputs.reduce(
|
||
(sum, product) => sum + product.orders![month].amount,
|
||
0n,
|
||
),
|
||
)
|
||
: useInventoryPurchaseSchedule
|
||
? inventoryPurchaseSchedule!
|
||
.slice(0, periods)
|
||
.map((entry, month) =>
|
||
cents(
|
||
entry.amount,
|
||
`Compras para existências, mês ${month + 1}`,
|
||
),
|
||
)
|
||
: monthlyInventoryPurchases === null
|
||
? null
|
||
: Array<bigint>(periods).fill(monthlyInventoryPurchases);
|
||
const inventoryLeadMonthsByPeriod = useProductInventorySchedule
|
||
? Array<number>(periods).fill(0)
|
||
: useInventoryPurchaseSchedule
|
||
? inventoryPurchaseSchedule!
|
||
.slice(0, periods)
|
||
.map((entry) => entry.leadMonths)
|
||
: Array<number>(periods).fill(inventoryPurchaseLeadMonths);
|
||
const inventoryProjectionEnabled = inventoryPurchasesByPeriod !== null;
|
||
const operatingExpenses = cents(
|
||
input.monthlyOperatingExpenses,
|
||
'Despesas operacionais',
|
||
);
|
||
const moneySchedule = (
|
||
schedule: string[] | undefined,
|
||
base: bigint,
|
||
label: string,
|
||
) => {
|
||
if (schedule === undefined) return Array(periods).fill(base);
|
||
if (
|
||
!Array.isArray(schedule) ||
|
||
schedule.length < periods ||
|
||
schedule.length > 60
|
||
)
|
||
throw new Error(
|
||
`${label}: a agenda deve cobrir os ${periods} meses e ter no maximo 60 meses.`,
|
||
);
|
||
return schedule
|
||
.slice(0, periods)
|
||
.map((amount, month) =>
|
||
cents(amount, `${label}, mes ${month + 1}`),
|
||
);
|
||
};
|
||
const supplierPaymentAmountsByPeriod = Array<bigint>(periods).fill(0n);
|
||
let supplierPaymentsOutstanding = 0n;
|
||
const supplierPaymentSchedules = useSupplierPaymentTerms
|
||
? input.products.map((product, index) =>
|
||
productStockInputs[index].orders!.flatMap((order, orderMonth) => {
|
||
let remaining = order.amount;
|
||
const events: {
|
||
orderMonth: number;
|
||
paymentMonth: number | null;
|
||
percent: string;
|
||
amount: string;
|
||
paidWithinHorizon: boolean;
|
||
}[] = order.paymentInstallments.map((part) => {
|
||
const rate = percentMicro(
|
||
part.percent,
|
||
'Percentagem da parcela',
|
||
);
|
||
const rounded =
|
||
(order.amount * rate + 50_000_000n) / 100_000_000n;
|
||
const amount = rounded > remaining ? remaining : rounded;
|
||
remaining -= amount;
|
||
const paymentMonth = orderMonth + 1 + part.lagMonths;
|
||
const paidWithinHorizon = paymentMonth <= periods;
|
||
if (paidWithinHorizon)
|
||
supplierPaymentAmountsByPeriod[paymentMonth - 1] +=
|
||
amount;
|
||
else supplierPaymentsOutstanding += amount;
|
||
return {
|
||
orderMonth: orderMonth + 1,
|
||
paymentMonth,
|
||
percent: part.percent,
|
||
amount: format(amount),
|
||
paidWithinHorizon,
|
||
};
|
||
});
|
||
if (remaining > 0n) {
|
||
supplierPaymentsOutstanding += remaining;
|
||
events.push({
|
||
orderMonth: orderMonth + 1,
|
||
paymentMonth: null,
|
||
percent: 'remaining',
|
||
amount: format(remaining),
|
||
paidWithinHorizon: false,
|
||
});
|
||
}
|
||
return events;
|
||
}),
|
||
)
|
||
: null;
|
||
const variableCostPaymentsByPeriod = useSupplierPaymentTerms
|
||
? supplierPaymentAmountsByPeriod
|
||
: moneySchedule(
|
||
input.monthlyVariableCostPaymentsByPeriod ?? undefined,
|
||
variablePayments,
|
||
'Pagamentos de compras',
|
||
);
|
||
const operatingExpenseByPeriod = moneySchedule(
|
||
input.monthlyOperatingExpensesByPeriod,
|
||
operatingExpenses,
|
||
'Despesas operacionais',
|
||
);
|
||
const operatingPayments = cents(
|
||
input.monthlyOperatingPayments,
|
||
'Pagamentos operacionais',
|
||
);
|
||
const operatingPaymentsByPeriod = moneySchedule(
|
||
input.monthlyOperatingPaymentsByPeriod,
|
||
operatingPayments,
|
||
'Pagamentos operacionais',
|
||
);
|
||
const payrollExpense = cents(
|
||
input.monthlyPayrollExpense,
|
||
'Despesa com pessoal',
|
||
);
|
||
const payrollExpenseByPeriod = moneySchedule(
|
||
input.monthlyPayrollExpenseByPeriod,
|
||
payrollExpense,
|
||
'Despesa com pessoal',
|
||
);
|
||
const payrollPayments = cents(
|
||
input.monthlyPayrollPayments,
|
||
'Pagamentos de pessoal',
|
||
);
|
||
const payrollPaymentsByPeriod = moneySchedule(
|
||
input.monthlyPayrollPaymentsByPeriod,
|
||
payrollPayments,
|
||
'Pagamentos de pessoal',
|
||
);
|
||
const assetInvestment = cents(
|
||
input.assetInvestment,
|
||
'Investimento em activos',
|
||
);
|
||
const inventory = cents(input.inventory, 'Existências iniciais');
|
||
const productOpeningInventory = productStockInputs.reduce(
|
||
(sum, product) => sum + (product.opening ?? 0n),
|
||
0n,
|
||
);
|
||
if (useProductInventorySchedule && productOpeningInventory !== inventory)
|
||
throw new Error(
|
||
'A soma das existencias iniciais por produto deve coincidir com as existencias iniciais do plano.',
|
||
);
|
||
const receivables = cents(input.receivables, 'Valores a receber');
|
||
const payables = cents(input.payables, 'Valores a pagar');
|
||
const equity = cents(input.equity, 'Capital próprio');
|
||
const shareholderLoans = cents(input.shareholderLoans, 'Suprimentos');
|
||
const bankLoanProceeds = cents(
|
||
input.bankLoanProceeds,
|
||
'Empréstimos recebidos',
|
||
);
|
||
const grants = cents(input.grants, 'Subvenções');
|
||
const assetDepreciation = cents(
|
||
input.assetDepreciation,
|
||
'Base contabilística dos activos',
|
||
);
|
||
const interestExpense = cents(
|
||
input.monthlyInterestExpense,
|
||
'Juros reconhecidos',
|
||
);
|
||
const interestPayment = cents(
|
||
input.monthlyInterestPayment,
|
||
'Pagamento de juros',
|
||
);
|
||
const principalRepayment = cents(
|
||
input.monthlyPrincipalRepayment,
|
||
'Reembolso de capital',
|
||
);
|
||
|
||
const workingCapital = inventory + receivables - payables;
|
||
const initialUses = assetInvestment + workingCapital;
|
||
const financing = equity + shareholderLoans + bankLoanProceeds + grants;
|
||
const fundingGap = initialUses - financing;
|
||
const monthlyDepreciation =
|
||
(assetDepreciation + BigInt(input.depreciationLifeMonths) / 2n) /
|
||
BigInt(input.depreciationLifeMonths);
|
||
const openingCashInput = cents(input.openingCash, 'Saldo inicial', true);
|
||
let cash = openingCashInput + financing - initialUses;
|
||
let inventoryBalance = inventory;
|
||
let inventoryInTransit = 0n;
|
||
let totalInventoryReceipts = 0n;
|
||
let totalInventoryShortfall = 0n;
|
||
let totalProfitBeforeTax = 0n;
|
||
let totalCashCollections = 0n;
|
||
let totalSalesCollections = 0n;
|
||
let totalScheduledSalesCollections = 0n;
|
||
let totalSalesRecoveryCollections = 0n;
|
||
let totalOpeningReceivablesCollections = 0n;
|
||
let salesReceivables = 0n;
|
||
let totalOperatingPayments = 0n;
|
||
const inventoryArrivals = new Map<number, bigint>();
|
||
const productInventoryStates = useProductInventorySchedule
|
||
? productStockInputs.map((product) => ({
|
||
balance: product.opening!,
|
||
inTransit: 0n,
|
||
arrivals: new Map<number, bigint>(),
|
||
totalReceipts: 0n,
|
||
totalShortfall: 0n,
|
||
monthlyRows: [] as {
|
||
month: number;
|
||
opening: string;
|
||
purchases: string;
|
||
leadMonths: number;
|
||
paymentLagMonths: number;
|
||
receipts: string;
|
||
inTransit: string;
|
||
consumption: string;
|
||
shortfall: string;
|
||
closing: string;
|
||
}[],
|
||
}))
|
||
: [];
|
||
const salesInvoiceCohorts: { dueMonth: number; balance: bigint }[] = [];
|
||
const openingReceivablesCollectionAmount =
|
||
openingReceivablesCollectionMonth === null
|
||
? 0n
|
||
: (receivables * openingReceivablesCollectionRate + 50_000_000n) /
|
||
100_000_000n;
|
||
const openingReceivablesInstallmentSchedule = new Map<number, bigint>();
|
||
const openingReceivablesInstallmentAmounts: {
|
||
month: number;
|
||
percent: string;
|
||
amount: bigint;
|
||
}[] = [];
|
||
let remainingForInstallments =
|
||
receivables - openingReceivablesCollectionAmount;
|
||
openingReceivablesInstallments.forEach((installment, index) => {
|
||
const roundedAmount =
|
||
(receivables * openingReceivablesInstallmentRates[index] +
|
||
50_000_000n) /
|
||
100_000_000n;
|
||
const amount =
|
||
roundedAmount > remainingForInstallments
|
||
? remainingForInstallments
|
||
: roundedAmount;
|
||
remainingForInstallments -= amount;
|
||
openingReceivablesInstallmentSchedule.set(
|
||
installment.month,
|
||
(openingReceivablesInstallmentSchedule.get(installment.month) ??
|
||
0n) + amount,
|
||
);
|
||
openingReceivablesInstallmentAmounts.push({
|
||
month: installment.month,
|
||
percent: installment.percent,
|
||
amount,
|
||
});
|
||
});
|
||
const openingPayablesInstallmentSchedule = new Map<number, bigint>();
|
||
const openingPayablesInstallmentAmounts: {
|
||
month: number;
|
||
percent: string;
|
||
amount: bigint;
|
||
}[] = [];
|
||
let remainingOpeningPayables = payables;
|
||
openingPayablesInstallments.forEach((installment, index) => {
|
||
const roundedAmount =
|
||
(payables * openingPayablesInstallmentRates[index] + 50_000_000n) /
|
||
100_000_000n;
|
||
const amount =
|
||
roundedAmount > remainingOpeningPayables
|
||
? remainingOpeningPayables
|
||
: roundedAmount;
|
||
remainingOpeningPayables -= amount;
|
||
openingPayablesInstallmentSchedule.set(
|
||
installment.month,
|
||
(openingPayablesInstallmentSchedule.get(installment.month) ?? 0n) +
|
||
amount,
|
||
);
|
||
openingPayablesInstallmentAmounts.push({
|
||
month: installment.month,
|
||
percent: installment.percent,
|
||
amount,
|
||
});
|
||
});
|
||
let openingPayablesBalance = payables;
|
||
let openingReceivablesBalance = receivables;
|
||
const rows = [];
|
||
|
||
for (let month = 1; month <= periods; month++) {
|
||
const periodVariableCostPayment =
|
||
variableCostPaymentsByPeriod[month - 1];
|
||
const periodOperatingExpense = operatingExpenseByPeriod[month - 1];
|
||
const periodOperatingPayment = operatingPaymentsByPeriod[month - 1];
|
||
const periodPayrollExpense = payrollExpenseByPeriod[month - 1];
|
||
const periodPayrollPayment = payrollPaymentsByPeriod[month - 1];
|
||
const revenue = revenueByPeriod[month - 1];
|
||
const costOfSales = costOfSalesByPeriod[month - 1];
|
||
const _opening = cash;
|
||
const openingInventory = inventoryBalance;
|
||
let inventoryShortfall: bigint | null = null;
|
||
let closingInventory: bigint | null = null;
|
||
let inventoryReceipts: bigint | null = null;
|
||
let inventoryInTransitAtClose: bigint | null = null;
|
||
let inventoryProductRows: {
|
||
label: string;
|
||
opening: string;
|
||
purchases: string;
|
||
leadMonths: number;
|
||
paymentLagMonths: number;
|
||
receipts: string;
|
||
inTransit: string;
|
||
consumption: string;
|
||
shortfall: string;
|
||
closing: string;
|
||
}[] = [];
|
||
if (inventoryProjectionEnabled) {
|
||
const ordered = inventoryPurchasesByPeriod![month - 1];
|
||
const leadMonths = inventoryLeadMonthsByPeriod[month - 1];
|
||
if (useProductInventorySchedule) {
|
||
inventoryReceipts = 0n;
|
||
inventoryShortfall = 0n;
|
||
closingInventory = 0n;
|
||
inventoryInTransit = 0n;
|
||
inventoryProductRows = productInputs.map((product, index) => {
|
||
const stock = productInventoryStates[index];
|
||
const order = productStockInputs[index].orders![month - 1];
|
||
const arrivalMonth = month + order.leadMonths;
|
||
stock.arrivals.set(
|
||
arrivalMonth,
|
||
(stock.arrivals.get(arrivalMonth) ?? 0n) + order.amount,
|
||
);
|
||
const receipts = stock.arrivals.get(month) ?? 0n;
|
||
stock.arrivals.delete(month);
|
||
stock.inTransit += order.amount - receipts;
|
||
const consumption = multiplyQuantity(
|
||
product.units[month - 1],
|
||
product.costs[month - 1],
|
||
);
|
||
const available = stock.balance + receipts;
|
||
const shortfall =
|
||
consumption > available ? consumption - available : 0n;
|
||
stock.balance =
|
||
available > consumption ? available - consumption : 0n;
|
||
stock.totalReceipts += receipts;
|
||
stock.totalShortfall += shortfall;
|
||
inventoryReceipts! += receipts;
|
||
inventoryShortfall! += shortfall;
|
||
closingInventory! += stock.balance;
|
||
inventoryInTransit += stock.inTransit;
|
||
return {
|
||
label: input.products[index].label,
|
||
opening: format(available - receipts),
|
||
purchases: format(order.amount),
|
||
leadMonths: order.leadMonths,
|
||
paymentLagMonths: order.paymentLagMonths,
|
||
receipts: format(receipts),
|
||
inTransit: format(stock.inTransit),
|
||
consumption: format(consumption),
|
||
shortfall: format(shortfall),
|
||
closing: format(stock.balance),
|
||
};
|
||
});
|
||
productInventoryStates.forEach((state, index) => {
|
||
state.monthlyRows.push({
|
||
month,
|
||
...inventoryProductRows[index],
|
||
});
|
||
});
|
||
totalInventoryReceipts += inventoryReceipts;
|
||
totalInventoryShortfall += inventoryShortfall;
|
||
inventoryBalance = closingInventory;
|
||
inventoryInTransitAtClose = inventoryInTransit;
|
||
} else {
|
||
const arrivalMonth = month + leadMonths;
|
||
inventoryArrivals.set(
|
||
arrivalMonth,
|
||
(inventoryArrivals.get(arrivalMonth) ?? 0n) + ordered,
|
||
);
|
||
inventoryReceipts = inventoryArrivals.get(month) ?? 0n;
|
||
inventoryArrivals.delete(month);
|
||
inventoryInTransit += ordered - inventoryReceipts;
|
||
totalInventoryReceipts += inventoryReceipts;
|
||
const availableInventory = openingInventory + inventoryReceipts;
|
||
inventoryShortfall =
|
||
costOfSales > availableInventory
|
||
? costOfSales - availableInventory
|
||
: 0n;
|
||
closingInventory =
|
||
availableInventory > costOfSales
|
||
? availableInventory - costOfSales
|
||
: 0n;
|
||
inventoryBalance = closingInventory;
|
||
inventoryInTransitAtClose = inventoryInTransit;
|
||
totalInventoryShortfall += inventoryShortfall;
|
||
}
|
||
}
|
||
const depreciation =
|
||
month < input.depreciationLifeMonths
|
||
? monthlyDepreciation
|
||
: month === input.depreciationLifeMonths
|
||
? assetDepreciation -
|
||
monthlyDepreciation *
|
||
BigInt(input.depreciationLifeMonths - 1)
|
||
: 0n;
|
||
const operatingResult =
|
||
revenue -
|
||
costOfSales -
|
||
periodOperatingExpense -
|
||
periodPayrollExpense -
|
||
depreciation;
|
||
const profitBeforeTax = operatingResult - interestExpense;
|
||
let salesCollections: bigint | null = null;
|
||
let salesScheduledCollections: bigint | null = null;
|
||
let salesRecoveryCollections: bigint | null = null;
|
||
if (salesCollectionLagMonths !== null) {
|
||
const collectionMonth = month + salesCollectionLagMonths;
|
||
const scheduledCollection =
|
||
(revenue * salesCollectionRate + 50_000_000n) / 100_000_000n;
|
||
salesInvoiceCohorts.push({
|
||
dueMonth: collectionMonth,
|
||
balance: revenue,
|
||
});
|
||
salesReceivables += revenue;
|
||
salesScheduledCollections = 0n;
|
||
for (const invoice of salesInvoiceCohorts) {
|
||
if (invoice.dueMonth !== month || invoice.balance === 0n)
|
||
continue;
|
||
const collected =
|
||
invoice.balance < scheduledCollection
|
||
? invoice.balance
|
||
: scheduledCollection;
|
||
invoice.balance -= collected;
|
||
salesReceivables -= collected;
|
||
salesScheduledCollections += collected;
|
||
}
|
||
const recoveryRate = salesRecoveryRates.get(month) ?? 0n;
|
||
let recoveryTarget =
|
||
(salesInvoiceCohorts
|
||
.filter((invoice) => invoice.dueMonth < month)
|
||
.reduce((sum, invoice) => sum + invoice.balance, 0n) *
|
||
recoveryRate +
|
||
50_000_000n) /
|
||
100_000_000n;
|
||
salesRecoveryCollections = recoveryTarget;
|
||
for (const invoice of salesInvoiceCohorts) {
|
||
if (invoice.dueMonth >= month || recoveryTarget <= 0n) continue;
|
||
const recovered =
|
||
invoice.balance < recoveryTarget
|
||
? invoice.balance
|
||
: recoveryTarget;
|
||
invoice.balance -= recovered;
|
||
recoveryTarget -= recovered;
|
||
}
|
||
salesRecoveryCollections -= recoveryTarget;
|
||
salesReceivables -= salesRecoveryCollections;
|
||
salesCollections =
|
||
salesScheduledCollections + salesRecoveryCollections;
|
||
totalScheduledSalesCollections += salesScheduledCollections;
|
||
totalSalesRecoveryCollections += salesRecoveryCollections;
|
||
totalSalesCollections += salesCollections;
|
||
}
|
||
const totalCollections = monthlyCollections + (salesCollections ?? 0n);
|
||
const legacyOpeningReceivablesCollections =
|
||
month === openingReceivablesCollectionMonth
|
||
? openingReceivablesCollectionAmount
|
||
: 0n;
|
||
const openingReceivablesCollections =
|
||
legacyOpeningReceivablesCollections +
|
||
(openingReceivablesInstallmentSchedule.get(month) ?? 0n);
|
||
openingReceivablesBalance -= openingReceivablesCollections;
|
||
const openingPayablesPayments =
|
||
openingPayablesInstallmentSchedule.get(month) ?? 0n;
|
||
openingPayablesBalance -= openingPayablesPayments;
|
||
const allCollections = totalCollections + openingReceivablesCollections;
|
||
const totalPayments =
|
||
periodVariableCostPayment +
|
||
periodOperatingPayment +
|
||
periodPayrollPayment +
|
||
interestPayment +
|
||
principalRepayment +
|
||
openingPayablesPayments;
|
||
const netCashFlow = allCollections - totalPayments;
|
||
cash += netCashFlow;
|
||
totalProfitBeforeTax += profitBeforeTax;
|
||
totalCashCollections += allCollections;
|
||
totalOpeningReceivablesCollections += openingReceivablesCollections;
|
||
totalOperatingPayments += totalPayments;
|
||
rows.push({
|
||
month,
|
||
revenue: format(revenue),
|
||
cashCollections: format(allCollections),
|
||
openingReceivablesBalance: format(openingReceivablesBalance),
|
||
openingReceivablesCollections: format(
|
||
openingReceivablesCollections,
|
||
),
|
||
openingPayablesBalance: format(openingPayablesBalance),
|
||
openingPayablesPayments: format(openingPayablesPayments),
|
||
salesCollections:
|
||
salesCollections === null ? null : format(salesCollections),
|
||
salesScheduledCollections:
|
||
salesScheduledCollections === null
|
||
? null
|
||
: format(salesScheduledCollections),
|
||
salesRecoveryCollections:
|
||
salesRecoveryCollections === null
|
||
? null
|
||
: format(salesRecoveryCollections),
|
||
otherCollections:
|
||
salesCollections === null ? null : format(monthlyCollections),
|
||
salesReceivablesClosing:
|
||
salesCollections === null ? null : format(salesReceivables),
|
||
variableCost: format(costOfSales),
|
||
inventoryOpening: !inventoryProjectionEnabled
|
||
? null
|
||
: format(openingInventory),
|
||
inventoryPurchases: !inventoryProjectionEnabled
|
||
? null
|
||
: format(inventoryPurchasesByPeriod![month - 1]),
|
||
inventoryLeadMonths:
|
||
inventoryProjectionEnabled && !useProductInventorySchedule
|
||
? inventoryLeadMonthsByPeriod[month - 1]
|
||
: null,
|
||
inventoryReceipts:
|
||
inventoryReceipts === null ? null : format(inventoryReceipts),
|
||
inventoryInTransit:
|
||
inventoryInTransitAtClose === null
|
||
? null
|
||
: format(inventoryInTransitAtClose),
|
||
inventoryConsumption: format(costOfSales),
|
||
inventoryClosing:
|
||
closingInventory === null ? null : format(closingInventory),
|
||
inventoryShortfall:
|
||
inventoryShortfall === null ? null : format(inventoryShortfall),
|
||
operatingExpenses: format(periodOperatingExpense),
|
||
payrollExpense: format(periodPayrollExpense),
|
||
depreciation: format(depreciation),
|
||
interestExpense: format(interestExpense),
|
||
operatingResult: format(operatingResult),
|
||
profitBeforeTax: format(profitBeforeTax),
|
||
variableCostPayments: format(periodVariableCostPayment),
|
||
operatingPayments: format(periodOperatingPayment),
|
||
payrollPayments: format(periodPayrollPayment),
|
||
interestPayment: format(interestPayment),
|
||
principalRepayment: format(principalRepayment),
|
||
netCashFlow: format(netCashFlow),
|
||
closingCash: format(cash),
|
||
});
|
||
}
|
||
|
||
const inventoryProjection = !inventoryProjectionEnabled
|
||
? {
|
||
status: 'indeterminate' as const,
|
||
leadMonths:
|
||
useInventoryPurchaseSchedule || useProductInventorySchedule
|
||
? null
|
||
: inventoryPurchaseLeadMonths,
|
||
openingInventory: format(inventory),
|
||
monthlyPurchases: null,
|
||
purchaseSchedule: null,
|
||
totalPurchases: null,
|
||
totalReceipts: null,
|
||
totalConsumption: format(totalCostOfSales),
|
||
endingInventory: null,
|
||
inTransitAtEnd: null,
|
||
totalShortfall: null,
|
||
productSchedules: null,
|
||
}
|
||
: {
|
||
status:
|
||
totalInventoryShortfall > 0n
|
||
? ('estimated_with_shortfall' as const)
|
||
: ('estimated' as const),
|
||
leadMonths:
|
||
useInventoryPurchaseSchedule || useProductInventorySchedule
|
||
? null
|
||
: inventoryPurchaseLeadMonths,
|
||
openingInventory: format(inventory),
|
||
monthlyPurchases:
|
||
useInventoryPurchaseSchedule || useProductInventorySchedule
|
||
? null
|
||
: format(monthlyInventoryPurchases!),
|
||
purchaseSchedule:
|
||
useInventoryPurchaseSchedule || useProductInventorySchedule
|
||
? inventoryPurchasesByPeriod!.map((amount, index) => ({
|
||
month: index + 1,
|
||
amount: format(amount),
|
||
leadMonths: useProductInventorySchedule
|
||
? null
|
||
: inventoryLeadMonthsByPeriod[index],
|
||
}))
|
||
: null,
|
||
totalPurchases: format(
|
||
inventoryPurchasesByPeriod!.reduce(
|
||
(total, amount) => total + amount,
|
||
0n,
|
||
),
|
||
),
|
||
totalReceipts: format(totalInventoryReceipts),
|
||
totalConsumption: format(totalCostOfSales),
|
||
endingInventory: format(inventoryBalance),
|
||
inTransitAtEnd: format(inventoryInTransit),
|
||
totalShortfall: format(totalInventoryShortfall),
|
||
productSchedules: useProductInventorySchedule
|
||
? productInputs.map((product, index) => ({
|
||
label: input.products[index].label,
|
||
openingInventory: format(
|
||
productStockInputs[index].opening!,
|
||
),
|
||
purchaseSchedule: productStockInputs[index].orders!.map(
|
||
(order, month) => ({
|
||
month: month + 1,
|
||
amount: format(order.amount),
|
||
leadMonths: order.leadMonths,
|
||
paymentLagMonths: order.paymentLagMonths,
|
||
}),
|
||
),
|
||
paymentSchedule:
|
||
supplierPaymentSchedules?.[index] ?? null,
|
||
totalPurchases: format(
|
||
productStockInputs[index].orders!.reduce(
|
||
(sum, order) => sum + order.amount,
|
||
0n,
|
||
),
|
||
),
|
||
totalReceipts: format(
|
||
productInventoryStates[index].totalReceipts,
|
||
),
|
||
totalConsumption: format(
|
||
product.units.reduce(
|
||
(sum, units, month) =>
|
||
sum +
|
||
multiplyQuantity(
|
||
units,
|
||
product.costs[month],
|
||
),
|
||
0n,
|
||
),
|
||
),
|
||
endingInventory: format(
|
||
productInventoryStates[index].balance,
|
||
),
|
||
inTransitAtEnd: format(
|
||
productInventoryStates[index].inTransit,
|
||
),
|
||
totalShortfall: format(
|
||
productInventoryStates[index].totalShortfall,
|
||
),
|
||
monthlyRows: productInventoryStates[index].monthlyRows,
|
||
}))
|
||
: null,
|
||
};
|
||
|
||
const output = {
|
||
currency: input.currency,
|
||
workingCapital: format(workingCapital),
|
||
initialUses: format(initialUses),
|
||
financing: format(financing),
|
||
fundingGap: format(fundingGap),
|
||
fundingStatus:
|
||
fundingGap > 0n
|
||
? ('shortfall' as const)
|
||
: fundingGap < 0n
|
||
? ('surplus' as const)
|
||
: ('balanced' as const),
|
||
openingReceivables: format(receivables),
|
||
openingCashAfterInitialUses: format(
|
||
openingCashInput + financing - initialUses,
|
||
),
|
||
totalRevenue: format(totalRevenue),
|
||
totalCashCollections: format(totalCashCollections),
|
||
totalOpeningReceivablesCollections: format(
|
||
totalOpeningReceivablesCollections,
|
||
),
|
||
openingReceivablesAtEnd: format(openingReceivablesBalance),
|
||
totalOpeningPayablesPayments: format(payables - openingPayablesBalance),
|
||
openingPayablesAtEnd: format(openingPayablesBalance),
|
||
openingPayablesInstallments: openingPayablesInstallmentAmounts.map(
|
||
(installment) => ({
|
||
month: installment.month,
|
||
percent: installment.percent,
|
||
amount: format(installment.amount),
|
||
}),
|
||
),
|
||
openingReceivablesCollectionMonth,
|
||
openingReceivablesCollectionPercent:
|
||
openingReceivablesCollectionMonth === null
|
||
? null
|
||
: openingReceivablesCollectionPercent,
|
||
openingReceivablesInstallments:
|
||
openingReceivablesInstallmentAmounts.map((installment) => ({
|
||
month: installment.month,
|
||
percent: installment.percent,
|
||
amount: format(installment.amount),
|
||
})),
|
||
totalSalesCollections:
|
||
salesCollectionLagMonths === null
|
||
? null
|
||
: format(totalSalesCollections),
|
||
totalScheduledSalesCollections:
|
||
salesCollectionLagMonths === null
|
||
? null
|
||
: format(totalScheduledSalesCollections),
|
||
totalSalesRecoveryCollections:
|
||
salesCollectionLagMonths === null
|
||
? null
|
||
: format(totalSalesRecoveryCollections),
|
||
salesReceivablesAtEnd:
|
||
salesCollectionLagMonths === null ? null : format(salesReceivables),
|
||
salesCollectionLagMonths,
|
||
salesCollectionPercent:
|
||
salesCollectionLagMonths === null ? null : salesCollectionPercent,
|
||
totalPayments: format(totalOperatingPayments),
|
||
supplierPaymentsOutstandingAtEnd: useSupplierPaymentTerms
|
||
? format(supplierPaymentsOutstanding)
|
||
: null,
|
||
totalProfitBeforeTax: format(totalProfitBeforeTax),
|
||
endingCash: format(cash),
|
||
inventoryProjection,
|
||
rows,
|
||
status: 'estimated' as const,
|
||
};
|
||
const conversion =
|
||
fxRate === null
|
||
? null
|
||
: {
|
||
currency: presentationCurrency,
|
||
rate: input.fxRate!,
|
||
date: input.fxDate!,
|
||
source: input.fxSource!.trim(),
|
||
workingCapital: format(
|
||
convertCents(workingCapital, fxRate, 100_000_000n),
|
||
),
|
||
initialUses: format(
|
||
convertCents(initialUses, fxRate, 100_000_000n),
|
||
),
|
||
financing: format(
|
||
convertCents(financing, fxRate, 100_000_000n),
|
||
),
|
||
fundingGap: format(
|
||
convertCents(fundingGap, fxRate, 100_000_000n),
|
||
),
|
||
fundingStatus: output.fundingStatus,
|
||
totalRevenue: format(
|
||
convertCents(totalRevenue, fxRate, 100_000_000n),
|
||
),
|
||
totalCashCollections: format(
|
||
convertCents(totalCashCollections, fxRate, 100_000_000n),
|
||
),
|
||
totalOpeningReceivablesCollections: format(
|
||
convertCents(
|
||
totalOpeningReceivablesCollections,
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
openingReceivablesAtEnd: format(
|
||
convertCents(
|
||
receivables - totalOpeningReceivablesCollections,
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
openingReceivablesInstallments:
|
||
output.openingReceivablesInstallments?.map(
|
||
(installment) => ({
|
||
...installment,
|
||
amount: format(
|
||
convertCents(
|
||
cents(
|
||
installment.amount,
|
||
'Parcela de recebíveis',
|
||
),
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
}),
|
||
) ?? [],
|
||
totalOpeningPayablesPayments: format(
|
||
convertCents(
|
||
payables - openingPayablesBalance,
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
openingPayablesAtEnd: format(
|
||
convertCents(
|
||
openingPayablesBalance,
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
openingPayablesInstallments:
|
||
output.openingPayablesInstallments.map((installment) => ({
|
||
...installment,
|
||
amount: format(
|
||
convertCents(
|
||
cents(
|
||
installment.amount,
|
||
'Parcela de valores a pagar',
|
||
),
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
})),
|
||
totalSalesCollections:
|
||
output.totalSalesCollections === null
|
||
? null
|
||
: format(
|
||
convertCents(
|
||
totalSalesCollections,
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
totalScheduledSalesCollections:
|
||
output.totalScheduledSalesCollections === null
|
||
? null
|
||
: format(
|
||
convertCents(
|
||
totalScheduledSalesCollections,
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
totalSalesRecoveryCollections:
|
||
output.totalSalesRecoveryCollections === null
|
||
? null
|
||
: format(
|
||
convertCents(
|
||
totalSalesRecoveryCollections,
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
salesReceivablesAtEnd:
|
||
output.salesReceivablesAtEnd === null
|
||
? null
|
||
: format(
|
||
convertCents(
|
||
salesReceivables,
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
totalPayments: format(
|
||
convertCents(
|
||
totalOperatingPayments,
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
totalProfitBeforeTax: format(
|
||
convertCents(totalProfitBeforeTax, fxRate, 100_000_000n),
|
||
),
|
||
endingCash: format(convertCents(cash, fxRate, 100_000_000n)),
|
||
inventoryProjection: {
|
||
status: inventoryProjection.status,
|
||
leadMonths: inventoryProjection.leadMonths,
|
||
openingInventory: format(
|
||
convertCents(
|
||
cents(
|
||
inventoryProjection.openingInventory,
|
||
'Existências iniciais',
|
||
true,
|
||
),
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
monthlyPurchases:
|
||
inventoryProjection.monthlyPurchases === null
|
||
? null
|
||
: format(
|
||
convertCents(
|
||
cents(
|
||
inventoryProjection.monthlyPurchases,
|
||
'Compras para existências',
|
||
true,
|
||
),
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
totalPurchases:
|
||
inventoryProjection.totalPurchases === null
|
||
? null
|
||
: format(
|
||
convertCents(
|
||
cents(
|
||
inventoryProjection.totalPurchases,
|
||
'Compras totais',
|
||
true,
|
||
),
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
totalReceipts:
|
||
inventoryProjection.totalReceipts === null
|
||
? null
|
||
: format(
|
||
convertCents(
|
||
cents(
|
||
inventoryProjection.totalReceipts,
|
||
'Recepções totais',
|
||
true,
|
||
),
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
totalConsumption: format(
|
||
convertCents(
|
||
cents(
|
||
inventoryProjection.totalConsumption,
|
||
'Consumo total',
|
||
true,
|
||
),
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
endingInventory:
|
||
inventoryProjection.endingInventory === null
|
||
? null
|
||
: format(
|
||
convertCents(
|
||
cents(
|
||
inventoryProjection.endingInventory,
|
||
'Existências finais',
|
||
true,
|
||
),
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
inTransitAtEnd:
|
||
inventoryProjection.inTransitAtEnd === null
|
||
? null
|
||
: format(
|
||
convertCents(
|
||
cents(
|
||
inventoryProjection.inTransitAtEnd,
|
||
'Existências em trânsito',
|
||
true,
|
||
),
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
totalShortfall:
|
||
inventoryProjection.totalShortfall === null
|
||
? null
|
||
: format(
|
||
convertCents(
|
||
cents(
|
||
inventoryProjection.totalShortfall,
|
||
'Falta de stock',
|
||
true,
|
||
),
|
||
fxRate,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
},
|
||
rows: rows.map((row) =>
|
||
Object.fromEntries(
|
||
Object.entries(row).map(([key, value]) => [
|
||
key,
|
||
key === 'month' || value === null
|
||
? value
|
||
: format(
|
||
convertCents(
|
||
cents(String(value), key, true),
|
||
fxRate!,
|
||
100_000_000n,
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
),
|
||
};
|
||
return { ...output, presentationCurrency, conversion };
|
||
}
|
||
|
||
/** Reproducible local report: original assumptions, declared FX basis, and the same forecast totals. */
|
||
export function exportBusinessPlanCsv(
|
||
input: BusinessFinancialPlanInput,
|
||
generatedAt = new Date().toISOString(),
|
||
): string {
|
||
const result = forecastBusinessPlan(input);
|
||
const fiscalLines = [
|
||
['Rendimentos facturados', 'income', 'revenue'],
|
||
['Custo variável dos produtos', 'expense', 'variableCost'],
|
||
['Despesas operacionais reconhecidas', 'expense', 'operatingExpenses'],
|
||
['Despesas com pessoal reconhecidas', 'expense', 'payrollExpense'],
|
||
['Depreciação contabilística', 'expense', 'depreciation'],
|
||
['Juros reconhecidos', 'expense', 'interestExpense'],
|
||
] as const;
|
||
const fiscalRows = fiscalLines.map(([origin, effect, field]) => {
|
||
const amounts = result.rows.map((row) => ({
|
||
period: row.month,
|
||
amount: row[field],
|
||
}));
|
||
const total = amounts.reduce(
|
||
(sum, row) => sum + cents(row.amount, origin, true),
|
||
0n,
|
||
);
|
||
return [
|
||
origin,
|
||
effect,
|
||
format(total),
|
||
'unclassified',
|
||
JSON.stringify(amounts),
|
||
];
|
||
});
|
||
let scenarioComparison: ReturnType<typeof compareBusinessScenarios> | null =
|
||
null;
|
||
if (input.scenarioAdjustments) {
|
||
const adjustmentValues = [
|
||
...Object.values(input.scenarioAdjustments.favorable),
|
||
...Object.values(input.scenarioAdjustments.unfavorable),
|
||
];
|
||
const hasScenarioChanges = adjustmentValues.some(
|
||
(value) => value !== '',
|
||
);
|
||
if (
|
||
hasScenarioChanges &&
|
||
adjustmentValues.every((value) => value !== '')
|
||
)
|
||
scenarioComparison = compareBusinessScenarios(input);
|
||
else if (hasScenarioChanges)
|
||
throw new Error(
|
||
'Complete ou limpe todas as variações antes de exportar os cenários.',
|
||
);
|
||
}
|
||
const rows: (string | number)[][] = [
|
||
['Relatório', 'Plano financeiro empresarial indicativo'],
|
||
['Versão do modelo', 'business-plan-v17'],
|
||
[
|
||
'Prazo de cobrança das vendas',
|
||
input.salesCollectionLagMonths === null ||
|
||
input.salesCollectionLagMonths === undefined
|
||
? 'agenda manual'
|
||
: `${input.salesCollectionLagMonths} meses`,
|
||
],
|
||
[
|
||
'Percentagem cobrada no prazo declarado',
|
||
input.salesCollectionLagMonths === null ||
|
||
input.salesCollectionLagMonths === undefined
|
||
? 'não aplicável; modo manual'
|
||
: `${input.salesCollectionPercent ?? '100'}%`,
|
||
],
|
||
[
|
||
'Agenda de recuperação de vendas vencidas',
|
||
JSON.stringify(input.salesRecoverySchedule ?? []),
|
||
],
|
||
[
|
||
'Mês de cobrança dos valores a receber de abertura',
|
||
result.openingReceivablesCollectionMonth ??
|
||
'sem previsão declarada',
|
||
],
|
||
[
|
||
'Percentagem de cobrança dos valores de abertura',
|
||
result.openingReceivablesCollectionPercent === null
|
||
? 'não aplicável'
|
||
: `${result.openingReceivablesCollectionPercent}%`,
|
||
],
|
||
[
|
||
'Parcelas adicionais de recebíveis de abertura',
|
||
JSON.stringify(result.openingReceivablesInstallments),
|
||
],
|
||
[
|
||
'Liquidação de valores a pagar de abertura',
|
||
JSON.stringify(result.openingPayablesInstallments),
|
||
],
|
||
['Data de exportação', generatedAt],
|
||
['Horizonte', `${input.periods ?? 12} meses`],
|
||
['Moeda de cálculo', result.currency],
|
||
['Moeda de apresentação', result.presentationCurrency],
|
||
[
|
||
'Fonte dos pressupostos',
|
||
'Valores do formulário; os valores iniciais podem ser exemplos demonstrativos',
|
||
],
|
||
['Facturação', 'unidades mensais × preço unitário'],
|
||
['Custo variável', 'unidades mensais × custo variável unitário'],
|
||
[
|
||
'Compras mensais para existências',
|
||
input.monthlyInventoryPurchases?.trim() ||
|
||
'não fornecidas; projeção de existências indeterminada',
|
||
],
|
||
[
|
||
'Prazo de entrega das compras',
|
||
`${input.inventoryPurchaseLeadMonths ?? 0} meses`,
|
||
],
|
||
[
|
||
'Movimento simplificado de existências',
|
||
'saldo inicial + recepções de compras − consumo mensal; encomendas chegam após o prazo de entrega; não reduz as vendas planeadas perante falta de stock',
|
||
],
|
||
[
|
||
'Resultado antes de impostos',
|
||
'facturação − custo variável − despesas operacionais − pessoal − depreciação contabilística − juros reconhecidos',
|
||
],
|
||
[
|
||
'Fluxo de caixa',
|
||
'cobranças − pagamentos declarados de valores a pagar de abertura − pagamentos de compras − pagamentos operacionais − pessoal − juros pagos − capital reembolsado',
|
||
],
|
||
[
|
||
'Depreciação',
|
||
'base contabilística / vida útil em meses; residual reconciliado no último mês',
|
||
],
|
||
['Impostos', 'não calculados nem incluídos'],
|
||
[
|
||
'Uso inicial',
|
||
'activos + existências + valores a receber − valores a pagar',
|
||
],
|
||
[
|
||
'Fontes iniciais',
|
||
'capital próprio + suprimentos + empréstimos + subvenções',
|
||
],
|
||
[
|
||
'Taxa de câmbio',
|
||
result.conversion ? result.conversion.rate : 'não aplicada',
|
||
],
|
||
['Data da taxa de câmbio', result.conversion?.date ?? 'não aplicada'],
|
||
[
|
||
'Fonte da taxa de câmbio',
|
||
result.conversion?.source ?? 'não aplicada',
|
||
],
|
||
[
|
||
'Estado da conversao cambial',
|
||
result.conversion
|
||
? 'pressuposto de planeamento declarado pelo utilizador; nao verificado pelo portal'
|
||
: 'nao aplicada',
|
||
],
|
||
[],
|
||
['Pressupostos por produto/serviço'],
|
||
[
|
||
'Produto/serviço',
|
||
'Unidades por mes (JSON; extras preservados)',
|
||
'Precos unitarios por mes (JSON; vazio = constante)',
|
||
'Custos unitarios por mes (JSON; vazio = constante)',
|
||
],
|
||
...input.products.map((product) => [
|
||
product.label,
|
||
product.monthlyUnitsByPeriod
|
||
? JSON.stringify(product.monthlyUnitsByPeriod)
|
||
: product.monthlyUnits,
|
||
product.unitPriceByPeriod
|
||
? JSON.stringify(product.unitPriceByPeriod)
|
||
: product.unitPrice,
|
||
product.unitVariableCostByPeriod
|
||
? JSON.stringify(product.unitVariableCostByPeriod)
|
||
: product.unitVariableCost,
|
||
]),
|
||
[],
|
||
['Pressupostos mensais de despesas e pessoal'],
|
||
[
|
||
'Despesas operacionais reconhecidas por mes (JSON; vazio = constante)',
|
||
JSON.stringify(input.monthlyOperatingExpensesByPeriod ?? []),
|
||
],
|
||
[
|
||
'Pagamentos operacionais por mes (JSON; vazio = constante)',
|
||
JSON.stringify(input.monthlyOperatingPaymentsByPeriod ?? []),
|
||
],
|
||
[
|
||
'Despesas de pessoal reconhecidas por mes (JSON; vazio = constante)',
|
||
JSON.stringify(input.monthlyPayrollExpenseByPeriod ?? []),
|
||
],
|
||
[
|
||
'Pagamentos de pessoal por mes (JSON; vazio = constante)',
|
||
JSON.stringify(input.monthlyPayrollPaymentsByPeriod ?? []),
|
||
],
|
||
[],
|
||
['Resumo', result.currency],
|
||
['Fundo de maneio líquido', result.workingCapital],
|
||
['Usos iniciais', result.initialUses],
|
||
['Fontes de financiamento', result.financing],
|
||
['Estado da reconciliação do financiamento', result.fundingStatus],
|
||
['Diferença entre usos e fontes', result.fundingGap],
|
||
['Facturação no horizonte', result.totalRevenue],
|
||
['Cobranças no horizonte', result.totalCashCollections],
|
||
[
|
||
'Cobrança de valores a receber de abertura',
|
||
result.totalOpeningReceivablesCollections,
|
||
],
|
||
[
|
||
'Valores de abertura por receber no fim',
|
||
result.openingReceivablesAtEnd,
|
||
],
|
||
[
|
||
'Pagamento de valores a pagar de abertura',
|
||
result.totalOpeningPayablesPayments,
|
||
],
|
||
[
|
||
'Valores a pagar de abertura pendentes no fim',
|
||
result.openingPayablesAtEnd,
|
||
],
|
||
[
|
||
'Cobranças da facturação modelada',
|
||
result.totalSalesCollections ?? 'agenda manual',
|
||
],
|
||
[
|
||
'Facturação por cobrar no fim',
|
||
result.salesReceivablesAtEnd ?? 'agenda manual',
|
||
],
|
||
['Resultado antes de impostos', result.totalProfitBeforeTax],
|
||
[
|
||
'Pagamentos a fornecedores fora do horizonte',
|
||
result.supplierPaymentsOutstandingAtEnd ?? 'nao aplicavel',
|
||
],
|
||
['Ponte contabilístico-fiscal', 'incomplete'],
|
||
['Matéria colectável', 'indeterminate'],
|
||
[
|
||
'Entradas fiscais em falta',
|
||
'expense_deductibility_classification; approved_tax_adjustments; applicable_tax_period; approved_effective_rule',
|
||
],
|
||
[
|
||
'Resultado contabilístico antes de impostos (ponte)',
|
||
result.totalProfitBeforeTax,
|
||
],
|
||
[],
|
||
[
|
||
'Linhas contabilísticas para classificação fiscal',
|
||
'Efeito',
|
||
'Total',
|
||
'Tratamento fiscal',
|
||
'Valores mensais',
|
||
],
|
||
...fiscalRows,
|
||
[
|
||
'Capital reembolsado',
|
||
'fluxo de caixa; excluído das despesas contabilísticas',
|
||
'',
|
||
'não aplicável à ponte de resultado',
|
||
'',
|
||
],
|
||
['Caixa final projectada', result.endingCash],
|
||
['Projecção de existências', result.inventoryProjection.status],
|
||
[
|
||
'Existências finais',
|
||
result.inventoryProjection.endingInventory ?? 'indeterminadas',
|
||
],
|
||
[
|
||
'Existências em trânsito no fim do horizonte',
|
||
result.inventoryProjection.inTransitAtEnd ?? 'indeterminadas',
|
||
],
|
||
[
|
||
'Recepções de compras no horizonte',
|
||
result.inventoryProjection.totalReceipts ?? 'indeterminadas',
|
||
],
|
||
[
|
||
'Falta total de stock estimada',
|
||
result.inventoryProjection.totalShortfall ?? 'indeterminada',
|
||
],
|
||
...(result.inventoryProjection.purchaseSchedule
|
||
? [
|
||
[
|
||
'Agenda mensal de compras e recepção',
|
||
JSON.stringify(
|
||
result.inventoryProjection.purchaseSchedule,
|
||
),
|
||
],
|
||
]
|
||
: []),
|
||
...(result.inventoryProjection.productSchedules
|
||
? [
|
||
[],
|
||
[
|
||
'Existencias por produto',
|
||
'Mes',
|
||
'Inicial',
|
||
'Encomendas',
|
||
'Prazo de entrega',
|
||
'Recepcoes',
|
||
'Em transito',
|
||
'Consumo',
|
||
'Falta',
|
||
'Final',
|
||
],
|
||
...result.inventoryProjection.productSchedules.flatMap(
|
||
(product) =>
|
||
product.monthlyRows.map((row) => [
|
||
product.label,
|
||
row.month,
|
||
row.opening,
|
||
row.purchases,
|
||
row.leadMonths,
|
||
row.receipts,
|
||
row.inTransit,
|
||
row.consumption,
|
||
row.shortfall,
|
||
row.closing,
|
||
]),
|
||
),
|
||
]
|
||
: []),
|
||
...(result.inventoryProjection.productSchedules?.some(
|
||
(product) => product.paymentSchedule !== null,
|
||
)
|
||
? [
|
||
[],
|
||
[
|
||
'Pagamentos a fornecedores por prazo declarado',
|
||
'Produto',
|
||
'Mes da encomenda',
|
||
'Mes de pagamento',
|
||
'Percentagem',
|
||
'Montante',
|
||
'Situacao no horizonte',
|
||
],
|
||
...result.inventoryProjection.productSchedules.flatMap(
|
||
(product) =>
|
||
product.paymentSchedule?.map((payment) => [
|
||
product.label,
|
||
payment.orderMonth,
|
||
payment.paymentMonth ?? 'sem prazo declarado',
|
||
payment.percent === 'remaining'
|
||
? 'saldo nao alocado'
|
||
: payment.percent,
|
||
payment.amount,
|
||
payment.paidWithinHorizon
|
||
? 'incluido no horizonte'
|
||
: 'por pagar',
|
||
]) ?? [],
|
||
),
|
||
]
|
||
: []),
|
||
...(scenarioComparison
|
||
? [
|
||
[],
|
||
['Comparação de cenários', 'Valores na moeda de cálculo'],
|
||
[
|
||
'Cenário',
|
||
'Facturação',
|
||
'Resultado antes impostos',
|
||
'Caixa final',
|
||
'Existências finais',
|
||
'Falta de stock',
|
||
],
|
||
[
|
||
'Base',
|
||
scenarioComparison.base.totalRevenue,
|
||
scenarioComparison.base.totalProfitBeforeTax,
|
||
scenarioComparison.base.endingCash,
|
||
scenarioComparison.base.inventoryProjection
|
||
.endingInventory ?? 'indeterminadas',
|
||
scenarioComparison.base.inventoryProjection
|
||
.totalShortfall ?? 'indeterminada',
|
||
],
|
||
[
|
||
'Favorável',
|
||
'Variações',
|
||
JSON.stringify(scenarioComparison.favorable.adjustments),
|
||
scenarioComparison.favorable.result.totalRevenue,
|
||
scenarioComparison.favorable.result.totalProfitBeforeTax,
|
||
scenarioComparison.favorable.result.endingCash,
|
||
scenarioComparison.favorable.result.inventoryProjection
|
||
.endingInventory ?? 'indeterminadas',
|
||
scenarioComparison.favorable.result.inventoryProjection
|
||
.totalShortfall ?? 'indeterminada',
|
||
],
|
||
[
|
||
'Desfavorável',
|
||
'Variações',
|
||
JSON.stringify(
|
||
scenarioComparison.unfavorable.adjustments,
|
||
),
|
||
scenarioComparison.unfavorable.result.totalRevenue,
|
||
scenarioComparison.unfavorable.result
|
||
.totalProfitBeforeTax,
|
||
scenarioComparison.unfavorable.result.endingCash,
|
||
scenarioComparison.unfavorable.result.inventoryProjection
|
||
.endingInventory ?? 'indeterminadas',
|
||
scenarioComparison.unfavorable.result.inventoryProjection
|
||
.totalShortfall ?? 'indeterminada',
|
||
],
|
||
]
|
||
: []),
|
||
...(result.conversion
|
||
? [
|
||
[],
|
||
['Resumo convertido', result.conversion.currency],
|
||
['Fundo de maneio líquido', result.conversion.workingCapital],
|
||
['Usos iniciais', result.conversion.initialUses],
|
||
['Fontes de financiamento', result.conversion.financing],
|
||
[
|
||
'Estado da reconciliação do financiamento',
|
||
result.conversion.fundingStatus,
|
||
],
|
||
[
|
||
'Diferença entre usos e fontes',
|
||
result.conversion.fundingGap,
|
||
],
|
||
['Facturação no horizonte', result.conversion.totalRevenue],
|
||
[
|
||
'Cobranças no horizonte',
|
||
result.conversion.totalCashCollections,
|
||
],
|
||
[
|
||
'Cobrança de valores a receber de abertura',
|
||
result.conversion.totalOpeningReceivablesCollections,
|
||
],
|
||
[
|
||
'Valores de abertura por receber no fim',
|
||
result.conversion.openingReceivablesAtEnd,
|
||
],
|
||
[
|
||
'Pagamento de valores a pagar de abertura',
|
||
result.conversion.totalOpeningPayablesPayments,
|
||
],
|
||
[
|
||
'Valores a pagar de abertura pendentes no fim',
|
||
result.conversion.openingPayablesAtEnd,
|
||
],
|
||
[
|
||
'Resultado antes de impostos',
|
||
result.conversion.totalProfitBeforeTax,
|
||
],
|
||
['Caixa final projectada', result.conversion.endingCash],
|
||
]
|
||
: []),
|
||
[],
|
||
[
|
||
'Mês',
|
||
'Facturação',
|
||
'Cobranças',
|
||
'Cobranças de vendas',
|
||
'Cobranças iniciais das vendas',
|
||
'Recuperações de vendas vencidas',
|
||
'Cobrança de valores de abertura',
|
||
'Valores de abertura por receber',
|
||
'Pagamento de valores a pagar de abertura',
|
||
'Saldo de valores a pagar de abertura',
|
||
'Outras cobranças',
|
||
'Custo variável/consumo',
|
||
'Existências iniciais',
|
||
'Encomendas para existências',
|
||
'Prazo da encomenda (meses)',
|
||
'Recepções de compras',
|
||
'Existências em trânsito',
|
||
'Existências finais',
|
||
'Falta de stock',
|
||
'Despesas operacionais',
|
||
'Pagamentos operacionais',
|
||
'Pessoal',
|
||
'Pagamentos de pessoal',
|
||
'Depreciação',
|
||
'Juros',
|
||
'Pagamento de juros',
|
||
'Pagamento de compras',
|
||
'Resultado antes impostos',
|
||
'Pagamento capital',
|
||
'Fluxo líquido',
|
||
'Caixa final',
|
||
],
|
||
...result.rows.map((row) => [
|
||
row.month,
|
||
row.revenue,
|
||
row.cashCollections,
|
||
row.salesCollections ?? 'agenda manual',
|
||
row.salesScheduledCollections ?? 'agenda manual',
|
||
row.salesRecoveryCollections ?? 'agenda manual',
|
||
row.openingReceivablesCollections,
|
||
row.openingReceivablesBalance,
|
||
row.openingPayablesPayments,
|
||
row.openingPayablesBalance,
|
||
row.otherCollections ?? 'incluídas nas cobranças manuais',
|
||
row.variableCost,
|
||
row.inventoryOpening ?? 'indeterminado',
|
||
row.inventoryPurchases ?? 'indeterminadas',
|
||
row.inventoryLeadMonths ?? 'indeterminado',
|
||
row.inventoryReceipts ?? 'indeterminadas',
|
||
row.inventoryInTransit ?? 'indeterminadas',
|
||
row.inventoryClosing ?? 'indeterminadas',
|
||
row.inventoryShortfall ?? 'indeterminada',
|
||
row.operatingExpenses,
|
||
row.operatingPayments,
|
||
row.payrollExpense,
|
||
row.payrollPayments,
|
||
row.depreciation,
|
||
row.interestExpense,
|
||
row.interestPayment,
|
||
row.variableCostPayments,
|
||
row.profitBeforeTax,
|
||
row.principalRepayment,
|
||
row.netCashFlow,
|
||
row.closingCash,
|
||
]),
|
||
...(result.conversion
|
||
? [
|
||
[],
|
||
[
|
||
'Mês convertido',
|
||
'Facturação',
|
||
'Cobranças',
|
||
'Cobranças de vendas',
|
||
'Cobranças iniciais das vendas',
|
||
'Recuperações de vendas vencidas',
|
||
'Cobrança de valores de abertura',
|
||
'Valores de abertura por receber',
|
||
'Pagamento de valores a pagar de abertura',
|
||
'Saldo de valores a pagar de abertura',
|
||
'Outras cobranças',
|
||
'Resultado antes impostos',
|
||
'Fluxo líquido',
|
||
'Caixa final',
|
||
],
|
||
...result.conversion.rows.map((row) => [
|
||
row.month ?? '',
|
||
row.revenue ?? '',
|
||
row.cashCollections ?? '',
|
||
row.salesCollections ?? 'agenda manual',
|
||
row.salesScheduledCollections ?? '',
|
||
row.salesRecoveryCollections ?? '',
|
||
row.openingReceivablesCollections ?? '',
|
||
row.openingReceivablesBalance ?? '',
|
||
row.openingPayablesPayments ?? '',
|
||
row.openingPayablesBalance ?? '',
|
||
row.otherCollections ?? 'incluídas nas cobranças manuais',
|
||
row.profitBeforeTax ?? '',
|
||
row.netCashFlow ?? '',
|
||
row.closingCash ?? '',
|
||
]),
|
||
]
|
||
: []),
|
||
[],
|
||
[
|
||
'Limitações',
|
||
'Estimativa sem aconselhamento fiscal, parâmetros legais, sazonalidade, inflação ou validação contabilística. Moeda original preservada.',
|
||
],
|
||
];
|
||
return `\uFEFF${rows
|
||
.map((row) => row.map(csvCell).join(','))
|
||
.join('\r\n')}`;
|
||
}
|