#!/usr/bin/env node /* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, unicorn/prefer-module, n/prefer-global/process, no-console, unicorn/no-nested-ternary */ const fs = require('node:fs'); const path = require('node: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); function assertIdentifier(name) { if (!/^[A-Z_]\w*$/i.test(name)) { throw new Error(`Invalid identifier: ${name}`); } return name; } function ensureColumn(table, column, definition) { const safeTable = assertIdentifier(table); const safeColumn = assertIdentifier(column); const columns = db .prepare(`PRAGMA table_info(${safeTable})`) .all() .map((item) => item.name); if (!columns.includes(safeColumn)) { db.exec(`ALTER TABLE ${safeTable} ADD COLUMN ${definition}`); } } 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, status TEXT NOT NULL DEFAULT 'approved', status_updated_at TEXT, reimbursement_batch TEXT, review_notes TEXT, submitted_by TEXT, approved_by TEXT, approved_at TEXT, is_deleted INTEGER NOT NULL DEFAULT 0, deleted_at TEXT ); `); ensureColumn( 'finance_transactions', 'status', "status TEXT NOT NULL DEFAULT 'approved'", ); ensureColumn( 'finance_transactions', 'status_updated_at', 'status_updated_at TEXT', ); ensureColumn( 'finance_transactions', 'reimbursement_batch', 'reimbursement_batch TEXT', ); ensureColumn('finance_transactions', 'review_notes', 'review_notes TEXT'); ensureColumn('finance_transactions', 'submitted_by', 'submitted_by TEXT'); ensureColumn('finance_transactions', 'approved_by', 'approved_by TEXT'); ensureColumn('finance_transactions', 'approved_at', 'approved_at TEXT'); function ensureCurrency(code, name = code, symbol = code) { db.prepare( `INSERT OR IGNORE INTO finance_currencies (code, name, symbol, is_base, is_active) VALUES (?, ?, ?, ?, 1)`, ).run(code, name, symbol, code === 'CNY' ? 1 : 0); } function getCategoryId(name, type, icon = '📝') { const selectStmt = db.prepare( 'SELECT id FROM finance_categories WHERE name = ? LIMIT 1', ); const existing = selectStmt.get(name); if (existing) return existing.id; const insertStmt = db.prepare( `INSERT INTO finance_categories (name, type, icon, color, user_id, is_active) VALUES (?, ?, ?, ?, ?, 1)`, ); const info = insertStmt.run(name, type, icon, '#dfe4ea', 1); return Number(info.lastInsertRowid); } function getAccountId(name, currency, type = 'cash') { const selectStmt = db.prepare( 'SELECT id FROM finance_accounts WHERE name = ? LIMIT 1', ); const existing = selectStmt.get(name); if (existing) return existing.id; const insertStmt = db.prepare( `INSERT INTO finance_accounts (name, currency, type, icon, color, user_id, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)`, ); const info = insertStmt.run(name, currency, type, '💼', '#1677ff', 1); return Number(info.lastInsertRowid); } function ensureExchangeRate( fromCurrency, toCurrency, rate = 1, dateStr = '1970-01-01', ) { const stmt = db.prepare( `SELECT id FROM finance_exchange_rates WHERE from_currency = ? AND to_currency = ? ORDER BY date DESC LIMIT 1`, ); const existing = stmt.get(fromCurrency, toCurrency); if (existing) return existing.id; const insertStmt = db.prepare( `INSERT INTO finance_exchange_rates (from_currency, to_currency, rate, date, source) VALUES (?, ?, ?, ?, ?)`, ); const info = insertStmt.run( fromCurrency, toCurrency, rate, dateStr, 'import-script', ); return Number(info.lastInsertRowid); } function parseCategoryWithIcon(raw) { if (!raw) { return { icon: '📝', name: '未分类' }; } const trimmed = raw.trim(); const parts = trimmed.split(/\s+/); if (parts.length > 1 && /\p{Emoji}/u.test(parts[0])) { return { icon: parts[0], name: parts.slice(1).join(' ') }; } return { icon: '📝', name: trimmed }; } const RAW_TEXT = fs.readFileSync(inputPath, 'utf8').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(',').map((item) => item.trim()); const NEW_HEADER_KEYS = { date: '日期', type: '类型', category: '分类', project: '项目名称', amount: '金额', currency: '币种', account: '账户', }; const isNewFormat = Object.values(NEW_HEADER_KEYS).every((key) => header.includes(key), ); if (isNewFormat) { const DATE_IDX = header.indexOf(NEW_HEADER_KEYS.date); const TYPE_IDX = header.indexOf(NEW_HEADER_KEYS.type); const CATEGORY_IDX = header.indexOf(NEW_HEADER_KEYS.category); const PROJECT_IDX = header.indexOf(NEW_HEADER_KEYS.project); const AMOUNT_IDX = header.indexOf(NEW_HEADER_KEYS.amount); const CURRENCY_IDX = header.indexOf(NEW_HEADER_KEYS.currency); const ACCOUNT_IDX = header.indexOf(NEW_HEADER_KEYS.account); ensureCurrency('CNY', '人民币', '¥'); const insertTransactionStmt = 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, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted ) VALUES ( @type, @amount, @currency, @exchangeRate, @amountInBase, @categoryId, @accountId, @transactionDate, @description, NULL, NULL, @createdAt, 'approved', @statusUpdatedAt, NULL, NULL, NULL, NULL, @approvedAt, 0 )`, ); const importTx = db.transaction((records) => { for (const record of records) { insertTransactionStmt.run(record); } }); const transactions = []; for (let i = 1; i < lines.length; i += 1) { const columns = lines[i].split(',').map((item) => item.trim()); if (columns.length < header.length) continue; const date = columns[DATE_IDX]; if (!date) continue; const typeRaw = columns[TYPE_IDX]; const type = typeRaw && typeRaw.includes('收') ? 'income' : typeRaw && typeRaw.includes('入') ? 'income' : 'expense'; const categoryInfo = parseCategoryWithIcon(columns[CATEGORY_IDX]); ensureCurrency('CNY', '人民币', '¥'); const currency = (columns[CURRENCY_IDX] || 'CNY').toUpperCase(); ensureCurrency( currency, currency, currency === 'CNY' ? '¥' : currency === 'USD' ? '$' : currency, ); ensureExchangeRate(currency, 'CNY', 1, date); const accountName = columns[ACCOUNT_IDX] || '默认账户'; const accountId = getAccountId(accountName, currency); const categoryId = getCategoryId( categoryInfo.name, type, categoryInfo.icon, ); const amount = Number(columns[AMOUNT_IDX]?.replace(/,/g, '') || 0); if (Number.isNaN(amount) || amount === 0) continue; const description = columns[PROJECT_IDX] || ''; const createdAt = `${date}T09:00:00.000Z`; transactions.push({ type, amount: Math.abs(amount), currency, exchangeRate: 1, amountInBase: Math.abs(amount), categoryId, accountId, transactionDate: date, description, createdAt, statusUpdatedAt: createdAt, approvedAt: createdAt, }); } importTx(transactions); console.log(`导入完成,共写入 ${transactions.length} 条记录`); process.exit(0); } 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.exec(` DELETE FROM sqlite_sequence WHERE name IN ( 'finance_transactions', 'finance_accounts', 'finance_categories', 'finance_currencies', 'finance_exchange_rates' ) `); 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) .replaceAll(/[^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 === -1 ? '' : 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} 个。`, );