DriftLedger/src/domain/annualReport.ts
fengmengqi f6437b83fe feat: 初始化完整应用框架
- 切换到 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+ 单元测试覆盖核心逻辑
2026-07-15 10:01:31 +08:00

140 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 年度报告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);
}