- 切换到 expo-router 文件路由,删除 App.tsx - 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取), notification-listener, screenshot-monitor, sms-receiver - 实现核心领域逻辑:billPipeline (账单流水), dedup (去重), transferRecognizer (转账识别), ruleEngine + categories (双轨制分类), budgets, creditCards, recurring, sync, ocrProcessor - 增强 ledger.ts:支持 balance assertion, option, pad/note 指令, posting 级 metadata, cost/price 解析 - 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图, 分类管理, 信用卡, 定期交易, 规则管理 - 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore, metadataStore, automationStore + 持久化 - 新增 AI 功能:chatAssistant, monthlySummary, voiceInput - 实现多端同步:gitSync, webdavSync, icloudSync - 新增主题系统 (tokens/presets) 和 i18n (zh/en) - 添加 30+ 单元测试覆盖核心逻辑
140 lines
4.7 KiB
TypeScript
140 lines
4.7 KiB
TypeScript
/**
|
||
* 年度报告(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<string, string>();
|
||
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);
|
||
}
|