refactor: 整合财务系统到主应用并重构后端架构
主要变更: - 将独立的 web-finance 应用整合到 web-antd 主应用中 - 重命名 backend-mock 为 backend,增强后端功能 - 新增财务模块 API 端点(账户、预算、类别、交易) - 增强财务仪表板和报表功能 - 添加 SQLite 数据存储支持和财务数据导入脚本 - 优化路由结构,删除冗余的 finance-system 模块 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
363
apps/backend/scripts/import-finance-data.js
Normal file
363
apps/backend/scripts/import-finance-data.js
Normal file
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const params = {};
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg.startsWith('--')) {
|
||||
const key = arg.slice(2);
|
||||
const next = args[i + 1];
|
||||
if (!next || next.startsWith('--')) {
|
||||
params[key] = true;
|
||||
} else {
|
||||
params[key] = next;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.csv) {
|
||||
console.error('请通过 --csv <路径> 指定 CSV 数据文件');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputPath = path.resolve(params.csv);
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
console.error(`无法找到 CSV 文件: ${inputPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const baseYear = params.year ? Number(params.year) : 2024;
|
||||
if (Number.isNaN(baseYear)) {
|
||||
console.error('参数 --year 必须为数字');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const storeDir = path.join(process.cwd(), 'storage');
|
||||
fs.mkdirSync(storeDir, { recursive: true });
|
||||
const dbFile = path.join(storeDir, 'finance.db');
|
||||
const db = new Database(dbFile);
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_currencies (
|
||||
code TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
is_base INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_exchange_rates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_currency TEXT NOT NULL,
|
||||
to_currency TEXT NOT NULL,
|
||||
rate REAL NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
source TEXT DEFAULT 'manual'
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
type TEXT DEFAULT 'cash',
|
||||
balance REAL DEFAULT 0,
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
user_id INTEGER DEFAULT 1,
|
||||
is_active INTEGER DEFAULT 1
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
user_id INTEGER DEFAULT 1,
|
||||
is_active INTEGER DEFAULT 1
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
exchange_rate_to_base REAL NOT NULL,
|
||||
amount_in_base REAL NOT NULL,
|
||||
category_id INTEGER,
|
||||
account_id INTEGER,
|
||||
transaction_date TEXT NOT NULL,
|
||||
description TEXT,
|
||||
project TEXT,
|
||||
memo TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
const RAW_TEXT = fs.readFileSync(inputPath, 'utf-8').replace(/^\ufeff/, '');
|
||||
const lines = RAW_TEXT.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
if (lines.length <= 1) {
|
||||
console.error('CSV 文件内容为空');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const header = lines[0].split(',');
|
||||
const DATE_IDX = header.indexOf('日期');
|
||||
const PROJECT_IDX = header.indexOf('项目');
|
||||
const TYPE_IDX = header.indexOf('收支');
|
||||
const AMOUNT_IDX = header.indexOf('金额');
|
||||
const ACCOUNT_IDX = header.indexOf('支出人');
|
||||
const CATEGORY_IDX = header.indexOf('计入');
|
||||
const SHARE_IDX = header.indexOf('阿德应得分红');
|
||||
|
||||
if (DATE_IDX === -1 || PROJECT_IDX === -1 || TYPE_IDX === -1 || AMOUNT_IDX === -1 || ACCOUNT_IDX === -1 || CATEGORY_IDX === -1) {
|
||||
console.error('CSV 表头缺少必需字段');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const CURRENCIES = [
|
||||
{ code: 'CNY', name: '人民币', symbol: '¥', isBase: true },
|
||||
{ code: 'USD', name: '美元', symbol: '$', isBase: false },
|
||||
{ code: 'THB', name: '泰铢', symbol: '฿', isBase: false },
|
||||
];
|
||||
|
||||
const EXCHANGE_RATES = [
|
||||
{ fromCurrency: 'CNY', toCurrency: 'CNY', rate: 1, date: `${baseYear}-01-01`, source: 'system' },
|
||||
{ fromCurrency: 'USD', toCurrency: 'CNY', rate: 7.14, date: `${baseYear}-01-01`, source: 'manual' },
|
||||
{ fromCurrency: 'THB', toCurrency: 'CNY', rate: 0.2, date: `${baseYear}-01-01`, source: 'manual' },
|
||||
];
|
||||
|
||||
const DEFAULT_EXPENSE_CATEGORY = '未分类支出';
|
||||
const DEFAULT_INCOME_CATEGORY = '未分类收入';
|
||||
|
||||
db.prepare('DELETE FROM finance_transactions').run();
|
||||
db.prepare('DELETE FROM finance_accounts').run();
|
||||
db.prepare('DELETE FROM finance_categories').run();
|
||||
db.prepare('DELETE FROM finance_currencies').run();
|
||||
db.prepare('DELETE FROM finance_exchange_rates').run();
|
||||
|
||||
db.transaction(() => {
|
||||
const insertCurrency = db.prepare(`
|
||||
INSERT INTO finance_currencies (code, name, symbol, is_base, is_active)
|
||||
VALUES (@code, @name, @symbol, @isBase, 1)
|
||||
`);
|
||||
for (const currency of CURRENCIES) {
|
||||
insertCurrency.run({
|
||||
code: currency.code,
|
||||
name: currency.name,
|
||||
symbol: currency.symbol,
|
||||
isBase: currency.isBase ? 1 : 0,
|
||||
});
|
||||
}
|
||||
const insertRate = db.prepare(`
|
||||
INSERT INTO finance_exchange_rates (from_currency, to_currency, rate, date, source)
|
||||
VALUES (@fromCurrency, @toCurrency, @rate, @date, @source)
|
||||
`);
|
||||
for (const rate of EXCHANGE_RATES) {
|
||||
insertRate.run(rate);
|
||||
}
|
||||
})();
|
||||
|
||||
function inferCurrency(accountName, amountText) {
|
||||
const name = accountName ?? '';
|
||||
const text = `${name}${amountText ?? ''}`;
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.includes('美金') || lower.includes('usd') || lower.includes('u$') || lower.includes('u ')) {
|
||||
return 'USD';
|
||||
}
|
||||
if (lower.includes('泰铢') || lower.includes('thb')) {
|
||||
return 'THB';
|
||||
}
|
||||
return 'CNY';
|
||||
}
|
||||
|
||||
function parseAmount(raw) {
|
||||
if (!raw) return 0;
|
||||
const matches = String(raw)
|
||||
.replace(/[^0-9.+-]/g, (char) => (char === '+' || char === '-' ? char : ' '))
|
||||
.match(/[-+]?\d+(?:\.\d+)?/g);
|
||||
if (!matches) return 0;
|
||||
return matches.map(Number).reduce((sum, value) => sum + value, 0);
|
||||
}
|
||||
|
||||
function normalizeDate(value, monthTracker) {
|
||||
const cleaned = value.trim();
|
||||
const match = cleaned.match(/(\d{1,2})月(\d{1,2})日/);
|
||||
if (!match) {
|
||||
throw new Error(`无法解析日期: ${value}`);
|
||||
}
|
||||
const month = Number(match[1]);
|
||||
const day = Number(match[2]);
|
||||
let year = baseYear;
|
||||
if (monthTracker.lastMonth !== null && month > monthTracker.lastMonth && monthTracker.wrapped) {
|
||||
year -= 1;
|
||||
}
|
||||
if (monthTracker.lastMonth !== null && month < monthTracker.lastMonth && !monthTracker.wrapped) {
|
||||
monthTracker.wrapped = true;
|
||||
}
|
||||
monthTracker.lastMonth = month;
|
||||
const iso = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
return iso;
|
||||
}
|
||||
|
||||
const accountMap = new Map();
|
||||
const categoryMap = new Map();
|
||||
|
||||
const insertAccount = db.prepare(`
|
||||
INSERT INTO finance_accounts (name, currency, type, balance, icon, color, user_id, is_active)
|
||||
VALUES (@name, @currency, @type, 0, @icon, @color, 1, 1)
|
||||
`);
|
||||
|
||||
const insertCategory = db.prepare(`
|
||||
INSERT INTO finance_categories (name, type, icon, color, user_id, is_active)
|
||||
VALUES (@name, @type, @icon, @color, 1, 1)
|
||||
`);
|
||||
|
||||
db.transaction(() => {
|
||||
if (!categoryMap.has(`${DEFAULT_INCOME_CATEGORY}-income`)) {
|
||||
const info = insertCategory.run({ name: DEFAULT_INCOME_CATEGORY, type: 'income', icon: '💰', color: '#10b981' });
|
||||
categoryMap.set(`${DEFAULT_INCOME_CATEGORY}-income`, info.lastInsertRowid);
|
||||
}
|
||||
if (!categoryMap.has(`${DEFAULT_EXPENSE_CATEGORY}-expense`)) {
|
||||
const info = insertCategory.run({ name: DEFAULT_EXPENSE_CATEGORY, type: 'expense', icon: '🏷️', color: '#6366f1' });
|
||||
categoryMap.set(`${DEFAULT_EXPENSE_CATEGORY}-expense`, info.lastInsertRowid);
|
||||
}
|
||||
})();
|
||||
|
||||
const monthTracker = { lastMonth: null, wrapped: false };
|
||||
let carryDate = '';
|
||||
const transactions = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i += 1) {
|
||||
const row = lines[i].split(',');
|
||||
while (row.length < header.length) row.push('');
|
||||
|
||||
const rawDate = row[DATE_IDX].trim();
|
||||
if (rawDate) {
|
||||
carryDate = normalizeDate(rawDate, monthTracker);
|
||||
}
|
||||
if (!carryDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const project = row[PROJECT_IDX].trim();
|
||||
const typeText = row[TYPE_IDX].trim();
|
||||
const amountRaw = row[AMOUNT_IDX].trim();
|
||||
const accountNameRaw = row[ACCOUNT_IDX].trim();
|
||||
const categoryRaw = row[CATEGORY_IDX].trim();
|
||||
const shareRaw = SHARE_IDX >= 0 ? row[SHARE_IDX].trim() : '';
|
||||
|
||||
const amount = parseAmount(amountRaw);
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedType = typeText.includes('收') && !typeText.includes('支') ? 'income' : 'expense';
|
||||
const accountName = accountNameRaw || '美金现金';
|
||||
const currency = inferCurrency(accountNameRaw, amountRaw);
|
||||
|
||||
if (!accountMap.has(accountName)) {
|
||||
const icon = currency === 'USD' ? '💵' : currency === 'THB' ? '💱' : '💰';
|
||||
const color = currency === 'USD' ? '#1677ff' : currency === 'THB' ? '#22c55e' : '#6366f1';
|
||||
const info = insertAccount.run({
|
||||
name: accountName,
|
||||
currency,
|
||||
type: 'cash',
|
||||
icon,
|
||||
color,
|
||||
});
|
||||
accountMap.set(accountName, Number(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
const categoryName = categoryRaw || (normalizedType === 'income' ? DEFAULT_INCOME_CATEGORY : DEFAULT_EXPENSE_CATEGORY);
|
||||
const categoryKey = `${categoryName}-${normalizedType}`;
|
||||
if (!categoryMap.has(categoryKey)) {
|
||||
const icon = normalizedType === 'income' ? '💰' : '🏷️';
|
||||
const color = normalizedType === 'income' ? '#10b981' : '#fb7185';
|
||||
const info = insertCategory.run({
|
||||
name: categoryName,
|
||||
type: normalizedType,
|
||||
icon,
|
||||
color,
|
||||
});
|
||||
categoryMap.set(categoryKey, Number(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
const descriptionParts = [];
|
||||
if (project) descriptionParts.push(project);
|
||||
if (categoryRaw) descriptionParts.push(`计入: ${categoryRaw}`);
|
||||
if (shareRaw) descriptionParts.push(`分红: ${shareRaw}`);
|
||||
|
||||
const description = descriptionParts.join(' | ');
|
||||
transactions.push({
|
||||
type: normalizedType,
|
||||
amount,
|
||||
currency,
|
||||
categoryId: categoryMap.get(categoryKey) ?? null,
|
||||
accountId: accountMap.get(accountName) ?? null,
|
||||
transactionDate: carryDate,
|
||||
description,
|
||||
project: project || null,
|
||||
memo: shareRaw || null,
|
||||
});
|
||||
}
|
||||
|
||||
const insertTransaction = db.prepare(`
|
||||
INSERT INTO finance_transactions (
|
||||
type,
|
||||
amount,
|
||||
currency,
|
||||
exchange_rate_to_base,
|
||||
amount_in_base,
|
||||
category_id,
|
||||
account_id,
|
||||
transaction_date,
|
||||
description,
|
||||
project,
|
||||
memo,
|
||||
created_at,
|
||||
is_deleted
|
||||
) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, 0)
|
||||
`);
|
||||
|
||||
const getRateStmt = db.prepare(`
|
||||
SELECT rate
|
||||
FROM finance_exchange_rates
|
||||
WHERE from_currency = ? AND to_currency = 'CNY'
|
||||
ORDER BY date DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const insertMany = db.transaction((items) => {
|
||||
for (const item of items) {
|
||||
const rateRow = getRateStmt.get(item.currency);
|
||||
const rate = rateRow ? rateRow.rate : 1;
|
||||
const amountInBase = +(item.amount * rate).toFixed(2);
|
||||
insertTransaction.run({
|
||||
...item,
|
||||
exchangeRateToBase: rate,
|
||||
amountInBase,
|
||||
createdAt: `${item.transactionDate}T00:00:00.000Z`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(transactions);
|
||||
|
||||
console.log(`已导入 ${transactions.length} 条交易,账户 ${accountMap.size} 个,分类 ${categoryMap.size} 个。`);
|
||||
Reference in New Issue
Block a user