/** * 年度报告(plan.md「5.2 年度报告」)。 */ import { addDecimals } from './decimal'; import type { Transaction } from './types'; export interface AnnualReport { year: number; totalIncome: string; totalExpense: string; netIncome: string; topCategories: { category: string; amount: string; percentage: number }[]; monthlyTrend: { month: string; income: string; expense: string }[]; dayOfWeekBreakdown: { day: string; total: string }[]; largestTransaction: Transaction | null; transactionCount: number; averageDailyExpense: string; } const DAY_NAMES = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; export function generateAnnualReport( transactions: Transaction[], year: number, ): AnnualReport { const yearTx = transactions.filter(t => t.date.startsWith(String(year))); const totalIncome = sumByDirection(yearTx, 'income'); const totalExpense = sumByDirection(yearTx, 'expense'); const netIncome = addDecimals([totalIncome, totalExpense]); // expense 是负数 return { year, totalIncome, totalExpense, netIncome, topCategories: getTopCategories(yearTx, 10), monthlyTrend: getMonthlyTrend(yearTx), dayOfWeekBreakdown: getDayOfWeekBreakdown(yearTx), largestTransaction: getLargestTransaction(yearTx), transactionCount: yearTx.length, averageDailyExpense: calculateAverageDaily(yearTx), }; } function sumByDirection(transactions: Transaction[], direction: 'income' | 'expense'): string { const amounts: string[] = []; for (const t of transactions) { for (const p of t.postings) { if (!p.amount) continue; const amt = parseFloat(p.amount); // 支出:Expenses 账户的正金额(Beancount 约定,费用账户正余额=支出) if (direction === 'expense' && p.account.startsWith('Expenses') && amt > 0) { amounts.push(p.amount); } // 收入:Income 账户的负金额(冲减收入)取绝对值,或正金额(某些约定) if (direction === 'income' && p.account.startsWith('Income')) { amounts.push(amt < 0 ? p.amount.slice(1) : p.amount); } } } return addDecimals(amounts); } function getTopCategories(transactions: Transaction[], limit: number): { category: string; amount: string; percentage: number }[] { const byCategory = new Map(); let total = '0'; for (const t of transactions) { for (const p of t.postings) { if (p.amount && p.account.startsWith('Expenses') && parseFloat(p.amount) > 0) { byCategory.set(p.account, addDecimals([byCategory.get(p.account) ?? '0', p.amount])); total = addDecimals([total, p.amount]); } } } const entries = [...byCategory.entries()] .map(([category, amount]) => ({ category, amount, percentage: parseFloat(total) > 0 ? Math.round((parseFloat(amount) / parseFloat(total)) * 10000) / 100 : 0, })) .sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount)) .slice(0, limit); return entries; } function getMonthlyTrend(transactions: Transaction[]): { month: string; income: string; expense: string }[] { const months: { month: string; income: string; expense: string }[] = []; for (let m = 1; m <= 12; m++) { const monthStr = String(m).padStart(2, '0'); const monthTx = transactions.filter(t => t.date.slice(5, 7) === monthStr); months.push({ month: monthStr, income: sumByDirection(monthTx, 'income'), expense: sumByDirection(monthTx, 'expense'), }); } return months; } function getDayOfWeekBreakdown(transactions: Transaction[]): { day: string; total: string }[] { const byDay = new Array(7).fill(0).map((_, i) => ({ day: DAY_NAMES[i], total: '0' })); for (const t of transactions) { const date = new Date(t.date.slice(0, 10)); const dow = date.getDay(); for (const p of t.postings) { if (p.amount && p.account.startsWith('Expenses') && parseFloat(p.amount) > 0) { byDay[dow].total = addDecimals([byDay[dow].total, p.amount]); } } } return byDay; } function getLargestTransaction(transactions: Transaction[]): Transaction | null { let largest: Transaction | null = null; let maxAmount = 0; for (const t of transactions) { for (const p of t.postings) { if (p.amount && p.amount.startsWith('-')) { const abs = parseFloat(p.amount.slice(1)); if (abs > maxAmount) { maxAmount = abs; largest = t; } } } } return largest; } function calculateAverageDaily(transactions: Transaction[]): string { const total = sumByDirection(transactions, 'expense'); const days = transactions.length > 0 ? new Set(transactions.map(t => t.date.slice(0, 10))).size : 1; return (parseFloat(total) / days).toFixed(2); }