chore: persist sqlite storage and support csv import
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
#!/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');
|
||||
|
||||
@@ -43,7 +44,7 @@ const dbFile = path.join(storeDir, 'finance.db');
|
||||
const db = new Database(dbFile);
|
||||
|
||||
function assertIdentifier(name) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
||||
if (!/^[A-Z_]\w*$/i.test(name)) {
|
||||
throw new Error(`Invalid identifier: ${name}`);
|
||||
}
|
||||
return name;
|
||||
@@ -156,6 +157,83 @@ 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) {
|
||||
@@ -163,7 +241,148 @@ if (lines.length <= 1) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const header = lines[0].split(',');
|
||||
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('收支');
|
||||
|
||||
Reference in New Issue
Block a user